{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "segmented-control",
  "type": "registry:ui",
  "title": "SegmentedControl",
  "description": "Compact pick-one control: one bordered strip, divided into segments.",
  "categories": [
    "forms"
  ],
  "registryDependencies": [
    "https://whiskeyjack.net/r/compact-control.json",
    "https://whiskeyjack.net/r/merge-refs.json",
    "https://whiskeyjack.net/r/use-reduced-motion.json",
    "https://whiskeyjack.net/r/use-sliding-indicator.json",
    "https://whiskeyjack.net/r/use-travel-direction.json",
    "https://whiskeyjack.net/r/utils.json"
  ],
  "files": [
    {
      "path": "components/ui/segmented-control.tsx",
      "type": "registry:ui",
      "target": "components/ui/segmented-control.tsx",
      "content": "import * as React from 'react'\nimport { cn } from '@/lib/utils'\nimport { useReducedMotion } from '@/hooks/use-reduced-motion'\nimport { useSlidingIndicator } from '@/hooks/use-sliding-indicator'\nimport { useTravelDirection } from '@/hooks/use-travel-direction'\nimport { useMergedRefs } from '@/lib/merge-refs'\nimport { COMPACT_CONTROL_HEIGHT, compactControlClass } from '@/lib/compact-control'\n\nexport interface SegmentedControlOption<T extends string = string> {\n  value: T\n  label: string\n  /**\n   * Optional element rendered BEFORE the label, on the same line. The DS bundles\n   * no icon library, so the caller passes the node: usually a Phosphor glyph,\n   * but anything works -- Icon Stack passes a thumbnail of the image each\n   * segment selects, because \"Main\" and \"Alternate\" say nothing about which\n   * artwork you are choosing.\n   */\n  icon?: React.ReactNode\n}\n\nexport interface SegmentedControlProps<T extends string = string> {\n  options: SegmentedControlOption<T>[]\n  value: T\n  onChange: (value: T) => void\n  /** Accessible name for the group. */\n  'aria-label'?: string\n  /**\n   * Render the icons alone, with each option's `label` becoming its accessible\n   * name and its tooltip rather than visible text.\n   *\n   * For the case where a pair of glyphs IS the vocabulary -- a sun and a moon, a\n   * contain and a cover arrow -- and spelling it out costs more room than it\n   * earns. It is all-or-nothing on purpose: a strip mixing labelled and bare\n   * segments reads as one of them having failed to render.\n   *\n   * `label` stays required, because an icon-only control still has to say what it\n   * is. Each segment gets both `aria-label` and `title` from it, per the\n   * accessibility rule for icon-only controls.\n   */\n  hideLabels?: boolean\n  /**\n   * `sm` (default, 32px) is the compact strip that sits on a line with body\n   * text, matching the size chips and toggle pills built from\n   * `compactControlClass`. `md` is 42px, the shared control height, for a\n   * segmented control standing on its own rather than trailing a settings row.\n   */\n  size?: 'sm' | 'md'\n  className?: string\n  /** Inline styles for the root element. */\n  style?: React.CSSProperties\n}\n\n/**\n * Compact pick-one control: one bordered strip, divided into segments.\n *\n * ## Why this is not a size variant of `ToggleGroup`\n *\n * `ToggleGroup` is a grid of separate tiles, each bordered, icon stacked above\n * the label, sized to be the primary choice on a settings screen. This is a\n * single bordered strip with internal dividers, icon beside the label, sized to\n * sit ON A LINE with body text as the trailing control of a labelled row.\n *\n * Those are two constructions, not two sizes. A `size=\"sm\"` on `ToggleGroup`\n * would have to change the border from per-tile to per-group, the icon from\n * stacked to inline, and the layout from a gap-separated grid to a joined strip\n * -- at which point the only thing the two share is the word \"toggle\". Keeping\n * them separate means each stays legible on its own terms.\n *\n * What they DO share is behaviour, and that is deliberate: same `radiogroup`\n * role, same roving `tabIndex`, same arrow-key wrap-around, same accent fill on\n * the selected option. A control that looks different should not also *act*\n * different, so the keyboard model here is `ToggleGroup`'s, and a test pins the\n * two against each other.\n *\n * Reach for `ToggleGroup` when the choice is the point of the screen, and this\n * when the choice is one row among many.\n *\n * ```tsx\n * <SegmentedControl\n *   aria-label={t('source.fit')}\n *   options={[\n *     { value: 'contain', label: t('source.contain'), icon: <ArrowsIn size={14} /> },\n *     { value: 'cover', label: t('source.cover'), icon: <ArrowsOut size={14} /> },\n *   ]}\n *   value={fit}\n *   onChange={setFit}\n * />\n * ```\n */\nfunction SegmentedControlInner<T extends string = string>(\n  { options, value, onChange, hideLabels, size = 'sm', className, ...rest }: SegmentedControlProps<T>,\n  ref: React.ForwardedRef<HTMLDivElement>,\n) {\n  const containerRef = React.useRef<HTMLDivElement>(null)\n  const mergedRef = useMergedRefs(containerRef, ref)\n  const reduced = useReducedMotion()\n  // The accent fill is one pill that slides, rather than a background switching\n  // between segments -- the same measurement the TabBar underline rides on.\n  const indicator = useSlidingIndicator(containerRef, value, {\n    attribute: 'data-segment',\n    changeKey: options.map((o) => o.value).join('\\n'),\n  })\n\n  // Which edge of the pill leads depends on the direction of travel, so\n  // `data-dir` names it. Shared with the two travelling nav highlights.\n  const direction = useTravelDirection(options.findIndex((o) => o.value === value))\n\n  // Deliberately identical to ToggleGroup's: both are single-select radiogroups,\n  // and a user who learns one should not have to relearn the other.\n  const handleKeyDown = React.useCallback(\n    (e: React.KeyboardEvent<HTMLDivElement>) => {\n      if (!options.length) return\n      const currentIndex = options.findIndex((o) => o.value === value)\n\n      let nextIndex: number | null = null\n      if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {\n        nextIndex = currentIndex < options.length - 1 ? currentIndex + 1 : 0\n      } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {\n        nextIndex = currentIndex > 0 ? currentIndex - 1 : options.length - 1\n      }\n\n      if (nextIndex !== null) {\n        e.preventDefault()\n        const next = options[nextIndex]\n        onChange(next.value as T)\n        const btn = containerRef.current?.querySelectorAll<HTMLElement>('[role=\"radio\"]')[nextIndex]\n        btn?.focus()\n      }\n    },\n    [options, value, onChange],\n  )\n\n  return (\n    <div\n      ref={mergedRef}\n      role=\"radiogroup\"\n      onKeyDown={handleKeyDown}\n      // `inline-flex` so the strip is as wide as its content and can sit at the\n      // end of a row rather than filling it, which is the whole point of it.\n      className={cn(\n        // A track with a pill riding inside it, like a wider Toggle: fully\n        // rounded, and `p-1` so the pill floats clear of the edge instead of\n        // butting against it. The padding shortens the segments without\n        // changing the OUTER height, so the strip still lines up with a size\n        // chip or a toggle pill built from `compactControlClass`.\n        // No `overflow-hidden`: it shaped the segments' square ends back when\n        // the strip was a bordered row of them, and now only clips a label that\n        // outgrows the track.\n        'relative inline-flex items-center rounded-full border p-1',\n        'bg-[var(--color-surface-light)] dark:bg-[var(--color-surface-dark)]',\n        size === 'md' ? 'h-control-md' : COMPACT_CONTROL_HEIGHT,\n        'border-[var(--color-border-light)] dark:border-[var(--color-border-dark)]',\n        className,\n      )}\n      {...rest}\n    >\n      {indicator && (\n        <span\n          aria-hidden\n          data-dir={direction}\n          className={cn(\n            // Under the segments (z-10) so their labels stay on top of it.\n            // `inset-y-1` matches the track's padding, so the pill is inset on\n            // all four sides rather than only the two the edges control.\n            'pointer-events-none absolute inset-y-1 z-[5] rounded-full bg-[var(--color-accent-500)] shadow-bevel-accent',\n            // Positioned by both edges, so `.wj-segment-pill` can give each its\n            // own easing and the pill stretches through the travel.\n            !reduced && 'wj-segment-pill',\n          )}\n          style={{\n            left: indicator.offset,\n            right: indicator.extent - (indicator.offset + indicator.size),\n          }}\n        />\n      )}\n      {options.map((option) => {\n        const isSelected = value === option.value\n        return (\n          <button\n            key={option.value}\n            data-segment={option.value}\n            role=\"radio\"\n            aria-checked={isSelected}\n            tabIndex={isSelected ? 0 : -1}\n            type=\"button\"\n            onClick={() => onChange(option.value as T)}\n            // Both, per the icon-only rule: aria-label names it for assistive\n            // tech, title for a sighted user who cannot read the glyph.\n            {...(hideLabels ? { 'aria-label': option.label, title: option.label } : null)}\n            className={cn(\n              size === 'md'\n                ? 'wj-focus-ring inline-flex h-full items-center gap-2 rounded-full px-4 text-sm font-medium'\n                : compactControlClass({ inset: true }),\n              // Square-ish when there is no text to give the segment width.\n              hideLabels && '!px-2',\n              // Above the pill, and transparent, so the pill can travel behind\n              // every segment rather than only the one it starts on.\n              'relative z-10 bg-transparent',\n              // Colour only, NOT background. A segment sits above the pill, so\n              // a fading hover fill paints over it -- most visibly when the\n              // pill slides one segment across to where the pointer already is.\n              // The fill switches instantly; only the label's colour eases.\n              'transition-[color]',\n              isSelected\n                ? 'text-[var(--color-accent-foreground)]'\n                : [\n                    'text-[var(--color-text-secondary-light)]',\n                    'hover:bg-[var(--color-warm-100)]',\n                    'dark:text-[var(--color-text-secondary-dark)]',\n                    'dark:hover:bg-[var(--color-surface-muted-dark)]',\n                  ],\n            )}\n          >\n            {option.icon && (\n              <span aria-hidden className=\"flex shrink-0 items-center\">\n                {option.icon}\n              </span>\n            )}\n            {!hideLabels && option.label}\n          </button>\n        )\n      })}\n    </div>\n  )\n}\n\nexport const SegmentedControl = React.forwardRef(SegmentedControlInner) as <\n  T extends string = string,\n>(\n  props: SegmentedControlProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> },\n) => React.ReactElement\n"
    }
  ],
  "docs": "Use when the choice is one row among many rather than the point of the screen -- the trailing control of a labelled row, sized to sit on a line with body text. ToggleGroup is the other shape: a grid of separate bordered tiles with the icon stacked above the label, sized to be the primary choice on a settings screen. They are two constructions rather than two sizes, since a size variant would have to move the border from per-tile to per-group, the icon from stacked to inline, and the layout from a gap-separated grid to a joined strip. Behaviour is deliberately identical -- same radiogroup role, same roving tabIndex, same arrow-key wrap-around -- and a test pins the two against each other, because a control that looks different should not also act different. The optional icon is any node: a Phosphor glyph usually, or a thumbnail when the label alone cannot say what is being chosen. Pass hideLabels when a pair of glyphs IS the vocabulary -- a sun and a moon, a contain and a cover arrow -- and spelling it out costs more room than it earns; label stays required and becomes each segment aria-label and title, per the icon-only rule. It is all-or-nothing on purpose, since a strip mixing labelled and bare segments reads as one of them having failed to render. Presentational and i18n-free: pass translated labels and an aria-label.",
  "meta": {
    "group": "forms",
    "related": [
      "toggle-group",
      "toggle",
      "option-tile"
    ],
    "exports": [
      "SegmentedControl"
    ],
    "siteSlug": "segmented-control"
  }
}
