{
  "name": "ToggleGroup",
  "type": "component",
  "description": "A set of toggle buttons sharing one selection and one tab stop.",
  "export": "ToggleGroup",
  "path": "@gtmi/strix-react/atoms/toggle-group",
  "figma": null,
  "figmaAbsent": "Base UI primitive. Figma draws no toggle group; `.button-selectable` is `SelectableTile`.",
  "parts": ["ToggleGroup", "ToggleGroupItem"],
  "props": [
    {
      "name": "value",
      "type": "readonly string[]",
      "description": "Controlled selection. An ARRAY even in single-selection mode -- Base UI's contract, kept rather than translated into shadcn's type=\"single\" discriminated union."
    },
    {
      "name": "defaultValue",
      "type": "readonly string[]",
      "description": "Uncontrolled counterpart of value."
    },
    {
      "name": "onValueChange",
      "type": "(value: string[], details) => void",
      "description": "Fired with the whole selection. In single mode this reports an empty array when the pressed item is clicked again, so a consumer holding a non-nullable string must guard."
    },
    {
      "name": "multiple",
      "type": "boolean",
      "default": "false",
      "description": "Allow more than one pressed item. Base UI's prop name; `type` is deliberately absent."
    },
    {
      "name": "variant",
      "type": "outline | plain",
      "default": "outline",
      "description": "outline draws one boundary around the group with hairlines between items; plain draws nothing and each item carries its own pressed fill. Passed to items through context, not by cloning them."
    },
    {
      "name": "orientation",
      "type": "horizontal | vertical",
      "default": "horizontal",
      "description": "Layout direction. Sets data-orientation and decides which arrow keys move between items."
    },
    {
      "name": "disabled",
      "type": "boolean",
      "default": "false",
      "description": "Makes the whole group inert. Individual items can be disabled on their own."
    },
    {
      "name": "loopFocus",
      "type": "boolean",
      "default": "true",
      "description": "Base UI's; arrow movement wraps past the ends."
    },
    {
      "name": "aria-label",
      "type": "string",
      "description": "Required in practice: the root is role=group, and an unnamed group announces as nothing. aria-labelledby works too."
    }
  ],
  "ai_tags": ["toggle", "group", "segmented", "selection", "primitive"],
  "since": "0.2.0",
  "tokens_used": [
    "--color-border-control",
    "--color-surface-selection-layer-strong",
    "--color-surface-hover",
    "--color-text-secondary",
    "--color-text-primary",
    "--color-text-disabled",
    "--color-border-focus",
    "--spacing-touch-target"
  ],
  "visual_source": "shadcn",
  "notes": "THE VISUAL CAME FROM shadcn RATHER THAN FROM THE DESIGN AUTHORITY, and that is a deliberate, approved departure recorded here because it is invisible six weeks later. Neither cr-web-demo nor the Figma 'Demo components library' draws a segmented control. `.button-selectable` is the nearest atom and is a DIFFERENT THING -- a bordered two-line tile, which is `SelectableTile`, and Plan E's roster records `toggle` -> `selectable-tile` for exactly that reason. A segmented control is not a tile: it is a group with a roving tabindex, single-or-multiple selection and one shared tab stop. Driven by dashboard-01's chart range switcher.\n\nBase UI 1.6.0 ships BOTH HALVES -- @base-ui/react/toggle-group and @base-ui/react/toggle -- and Strix used neither. They are TWO SINGLE COMPONENTS rather than one namespace of parts, unlike every other Base UI primitive wrapped here: Object.keys(ToggleGroup) is ['$$typeof','render'], so ToggleGroup.Item is undefined and would render as a component named undefined with no error naming the cause.\n\nEIGHT BEHAVIOURS READ OFF THE DOM RATHER THAN THE PROSE, and FOUR DIFFER FROM `Toolbar`, which is the primitive ButtonGroup wraps and the natural thing to assume this matches: (1) the root is <div role=\"group\" data-orientation>, a group and not a toolbar; (2) items are <button type=\"button\" aria-pressed> with data-pressed on the pressed one; (3) a roving tabindex, 0 on the first enabled item and -1 on the rest; (4) ArrowRight/ArrowLeft move and LOOP, loopFocus defaulting to true; (5) HOME AND END WORK -- Toolbar gives neither, which ButtonGroup's manifest records as a characterised gap, so no handler should be bolted on here; (6) A DISABLED ITEM SETS THE NATIVE `disabled` ATTRIBUTE AS WELL AS aria-disabled, so :disabled matches and every disabled:-prefixed utility fires, where Toolbar.Button sets only aria-disabled and silently fires none of them; (7) arrow navigation and End both SKIP a disabled item -- the End assertion was written the other way first and failed against correct behaviour, since landing a keyboard user on a control that cannot be activated would be the defect; (8) selection is single by default.\n\nTHE VALUE IS AN ARRAY IN BOTH MODES. shadcn's Radix flavour takes type=\"single\" with a bare string, which reads better for the common case and is a translation layer that has to invent a discriminated union, map it onto an array, and then disagree with Base UI's own types at the edges. Plan D's smart-prop rule applies: expose the substrate. The consequence a consumer must handle is in the Controlled story -- single mode reports an EMPTY array when the pressed item is clicked again, so `next[0] && setRange(next[0])` rather than `setRange(next[0])`.\n\nThe variant reaches items through CONTEXT, not React.cloneElement over children. Cloning breaks the moment a caller wraps an item in anything -- a Tooltip, a fragment from a .map -- and the failure is a silently unstyled item rather than an error. A test renders an item inside a fragment to hold that.\n\nThe divider is `border-s` with `first:border-s-0`, a logical border so it mirrors under dir=\"rtl\" instead of ending up on the wrong side of the last item. Focus is `outline` with a negative offset rather than a ring, because the outline variant's `overflow-hidden` would clip a ring on the first and last items.\n\nThe pressed state is NOT carried by fill alone: aria-pressed answers assistive technology, and the weight changes to semibold alongside the fill, which is what keeps it clear of WCAG 1.4.1. Contrast is derived from the component's own cva output through colorTokensIn, never named, and mutation-tested twice -- swapping the pressed fill for bg-highlight fails at 1.19:1 and 1.84:1 in dark, and swapping the group boundary for border-neutral-soft fails at 1.07:1 to 2.34:1 against 1.4.11's 3:1. --color-text-disabled is excluded on purpose, asserted rather than dropped, because 1.4.3 and 1.4.11 both exempt inactive controls.\n\nBoth `disabled:` and `aria-disabled:` treatments are written even though this primitive sets the native attribute, because the group's own disabled prop and any future Base UI change go through the same styles and a one-sided treatment is invisible until it is wrong. Written literally in both cases -- a name assembled from a template string is invisible to Tailwind's scanner and generates nothing."
}
