{
  "name": "toggle-group",
  "type": "registry:ui",
  "title": "Toggle Group",
  "description": "Related toggle buttons with shared single-select or multi-select state.",
  "registryDependencies": [
    "@siteplane/base",
    "@siteplane/separator",
    "@siteplane/toggle"
  ],
  "dependencies": [
    "@base-ui/react@1.5.0",
    "class-variance-authority@0.7.1"
  ],
  "files": [
    {
      "path": "src/components/ui/toggle-group.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport type { Toggle as TogglePrimitive } from \"@base-ui/react/toggle\";\nimport { ToggleGroup as ToggleGroupPrimitive } from \"@base-ui/react/toggle-group\";\nimport type { VariantProps } from \"class-variance-authority\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useResolvedControlSize } from \"@/components/ui/siteplane-provider\";\nimport { Separator } from \"@/components/ui/separator\";\nimport {\n  Toggle as ToggleComponent,\n  type toggleVariants,\n} from \"@/components/ui/toggle\";\n\nconst useIsomorphicLayoutEffect =\n  typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect;\n\ntype ToggleGroupContextValue = VariantProps<typeof toggleVariants> & {\n  single?: boolean;\n};\n\nexport const ToggleGroupContext: React.Context<ToggleGroupContextValue> =\n  React.createContext<ToggleGroupContextValue>({\n    single: false,\n    size: \"default\",\n    variant: \"default\",\n  });\n\nexport function ToggleGroup({\n  className,\n  variant = \"default\",\n  size,\n  orientation = \"horizontal\",\n  multiple = false,\n  width = \"auto\",\n  wrap = false,\n  children,\n  ...props\n}: ToggleGroupPrimitive.Props &\n  VariantProps<typeof toggleVariants> & {\n    width?: \"auto\" | \"full\";\n    wrap?: boolean;\n  }): React.ReactElement {\n  // Slide-Indicator nur bei Single-Select (bei Multi-Select koennen mehrere Items\n  // gleichzeitig aktiv sein -> kein einzelnes gleitendes Element moeglich).\n  const single = !multiple;\n  const resolvedSize = useResolvedControlSize(size);\n\n  const group = (\n    <ToggleGroupPrimitive\n      className={cn(\n        \"flex *:focus-visible:z-10 dark:*:[[data-slot=separator]:has(+[data-slot=toggle]:hover)]:before:bg-input/64 dark:*:[[data-slot=separator]:has(+[data-slot=toggle][data-pressed])]:before:bg-input dark:*:[[data-slot=toggle]:hover+[data-slot=separator]]:before:bg-input/64 dark:*:[[data-slot=toggle][data-pressed]+[data-slot=separator]]:before:bg-input\",\n        width === \"full\" ? \"w-full\" : \"w-fit\",\n        wrap && \"flex-wrap\",\n        orientation === \"horizontal\"\n          ? \"*:pointer-coarse:after:min-w-auto\"\n          : \"*:pointer-coarse:after:min-h-auto\",\n        variant === \"default\"\n          ? \"gap-0.5\"\n          : orientation === \"horizontal\"\n            ? \"*:not-first:rounded-s-none *:not-last:rounded-e-none *:not-first:border-s-0 *:not-last:border-e-0 *:not-first:not-data-[slot=separator]:before:-start-[0.5px] *:not-last:not-data-[slot=separator]:before:-end-[0.5px] *:not-first:before:rounded-s-none *:not-last:before:rounded-e-none\"\n            : \"flex-col *:not-first:rounded-t-none *:not-last:rounded-b-none *:not-first:border-t-0 *:not-last:border-b-0 *:not-first:not-data-[slot=separator]:before:-top-[0.5px] *:not-last:not-data-[slot=separator]:before:-bottom-[0.5px] *:not-first:before:rounded-t-none *:not-last:before:rounded-b-none *:data-[slot=toggle]:not-last:before:hidden dark:*:last:before:hidden dark:*:first:before:block\",\n        className,\n      )}\n      data-size={resolvedSize}\n      data-slot=\"toggle-group\"\n      data-variant={variant}\n      multiple={multiple}\n      orientation={orientation}\n      {...props}\n    >\n      <ToggleGroupContext.Provider value={{ single, size: resolvedSize, variant }}>\n        {children}\n      </ToggleGroupContext.Provider>\n    </ToggleGroupPrimitive>\n  );\n\n  if (!single) {\n    return group;\n  }\n\n  // Indikator als Geschwister der Gruppe (nicht als Kind): bricht NICHT die\n  // first/last-child Border-/Rundungslogik der outline-Variante und faengt keine\n  // *:-Gruppenstile ein. isolate + -z-10 haelt ihn hinter den (transparenten)\n  // Items, sodass er beim Wechsel sichtbar durchgleitet.\n  return (\n    <div className={cn(\"relative isolate\", width === \"full\" ? \"w-full\" : \"w-fit\")}>\n      {group}\n      <ToggleGroupSlideIndicator variant={variant} />\n    </div>\n  );\n}\n\n// Gleitender Selektions-Indikator (gleiche Logik wie Tabs/Pill-Toggle): misst die\n// aktive Option (Position, Groesse UND Rundung) und bewegt ein einzelnes Element\n// per translate/width/height mit Transition dorthin. Optik bleibt identisch zum\n// bisherigen pro-Item data-pressed-Hintergrund - nur dass er jetzt gleitet.\ntype IndicatorGeom = {\n  h: number;\n  radius: string;\n  w: number;\n  x: number;\n  y: number;\n};\n\nfunction ToggleGroupSlideIndicator({\n  variant,\n}: {\n  variant: VariantProps<typeof toggleVariants>[\"variant\"];\n}): React.ReactElement {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  const lastGeom = React.useRef<IndicatorGeom | null>(null);\n  const animation = React.useRef<Animation | null>(null);\n  const [box, setBox] = React.useState<IndicatorGeom & { visible: boolean }>({\n    h: 0,\n    radius: \"0px\",\n    visible: false,\n    w: 0,\n    x: 0,\n    y: 0,\n  });\n\n  useIsomorphicLayoutEffect(() => {\n    // Container = das ToggleGroup-Root (wir sind dessen erstes Kind). So sind wir\n    // unabhaengig davon, ob base-ui einen ref durchreicht.\n    const span = ref.current;\n    const container = span?.parentElement;\n    if (!span || !container) {\n      return;\n    }\n\n    const reduced = () =>\n      document.documentElement.getAttribute(\"data-motion-reduce\") === \"true\" ||\n      window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    const read = (): IndicatorGeom | null => {\n      const active = container.querySelector<HTMLElement>(\n        '[data-slot=\"toggle\"][data-pressed]',\n      );\n      if (!active) {\n        return null;\n      }\n      const cs = getComputedStyle(active);\n      return {\n        h: active.offsetHeight,\n        radius: `${cs.borderTopLeftRadius} ${cs.borderTopRightRadius} ${cs.borderBottomRightRadius} ${cs.borderBottomLeftRadius}`,\n        w: active.offsetWidth,\n        x: active.offsetLeft - container.clientLeft,\n        y: active.offsetTop - container.clientTop,\n      };\n    };\n\n    // animate=true nur bei Auswahl-Wechsel -> gleitet. Layout/Font-Resize ruft\n    // mit animate=false -> springt instant (kein \"Auffuellen\" beim Laden).\n    const update = (animate: boolean) => {\n      const geom = read();\n      if (!geom) {\n        animation.current?.cancel();\n        animation.current = null;\n        lastGeom.current = null;\n        setBox((prev) => ({ ...prev, visible: false }));\n        return;\n      }\n      const prev = lastGeom.current;\n      lastGeom.current = geom;\n      setBox({ ...geom, visible: true });\n\n      if (animate && prev && !reduced()) {\n        animation.current?.cancel();\n        animation.current = span.animate(\n          [\n            {\n              height: `${prev.h}px`,\n              translate: `${prev.x}px ${prev.y}px`,\n              width: `${prev.w}px`,\n            },\n            {\n              height: `${geom.h}px`,\n              translate: `${geom.x}px ${geom.y}px`,\n              width: `${geom.w}px`,\n            },\n          ],\n          { duration: 200, easing: \"cubic-bezier(0.16, 1, 0.3, 1)\" },\n        );\n      }\n    };\n\n    update(false); // initial: positionieren, nicht animieren\n    const mutations = new MutationObserver(() => update(true)); // Auswahl -> slide\n    mutations.observe(container, {\n      attributeFilter: [\"data-pressed\"],\n      attributes: true,\n      subtree: true,\n    });\n    const resize = new ResizeObserver(() => update(false)); // Layout/Font -> jump\n    resize.observe(container);\n    return () => {\n      animation.current?.cancel();\n      mutations.disconnect();\n      resize.disconnect();\n    };\n  }, []);\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"siteplane-toggle-pill pointer-events-none absolute top-0 left-0 -z-10\",\n        variant === \"outline\" ? \"bg-input/64 dark:bg-input\" : \"bg-input/64\",\n      )}\n      ref={ref}\n      style={{\n        borderRadius: box.radius,\n        height: box.h,\n        opacity: box.visible ? 1 : 0,\n        translate: `${box.x}px ${box.y}px`,\n        width: box.w,\n      }}\n    />\n  );\n}\n\nexport function ToggleGroupItem({\n  className,\n  children,\n  grow = false,\n  maxWidth,\n  padding = \"default\",\n  ...props\n}: TogglePrimitive.Props & {\n  grow?: boolean;\n  maxWidth?: \"label\";\n  padding?: \"default\" | \"comfortable\";\n}): React.ReactElement {\n  // Items erben Variante/Groesse immer von der Gruppe (segmentierter Look). Es gibt\n  // bewusst KEINE pro-Item-Ueberschreibung - der gleitende Indikator und die\n  // zusammengefuehrten Border-/Rundungsregeln setzen eine einheitliche Variante voraus.\n  const context = React.useContext(ToggleGroupContext);\n\n  const resolvedVariant = context.variant;\n  const resolvedSize = context.size;\n\n  return (\n    <ToggleComponent\n      className={cn(\n        // Single-Select: der aktive Hintergrund kommt vom gleitenden Indikator,\n        // der HINTER den Items liegt. Damit er beim Wechsel sichtbar durchgleitet\n        // (auch hinter den Items dazwischen), machen wir die Item-Hintergruende\n        // transparent - Borders, Hover und Textfarbe bleiben erhalten.\n        // Multi-Select behaelt seinen pro-Item-Hintergrund.\n        context.single &&\n          \"bg-transparent data-pressed:bg-transparent dark:bg-transparent dark:data-pressed:bg-transparent\",\n        grow && \"flex-1\",\n        maxWidth === \"label\" && \"max-w-40\",\n        padding === \"comfortable\" && \"px-3\",\n        className,\n      )}\n      data-size={resolvedSize}\n      data-variant={resolvedVariant}\n      size={resolvedSize}\n      variant={resolvedVariant}\n      {...props}\n    >\n      {children}\n    </ToggleComponent>\n  );\n}\n\nexport function ToggleGroupSeparator({\n  className,\n  orientation = \"vertical\",\n  ...props\n}: {\n  className?: string;\n} & React.ComponentProps<typeof Separator>): React.ReactElement {\n  return (\n    <Separator\n      className={cn(\n        \"pointer-events-none relative bg-input before:absolute before:inset-0 dark:before:bg-input/32\",\n        className,\n      )}\n      orientation={orientation}\n      {...props}\n    />\n  );\n}\n\nexport { ToggleGroupPrimitive };\n"
    }
  ],
  "categories": [
    "actions"
  ],
  "docs": "# Toggle Group\n\n> Siteplane UI release `0.1.1`.\n\n## Public Purpose\n\nUse Toggle Group for related toggle buttons that share single-select or multi-select state, such as formatting, alignment or view controls.\n\n## Import\n\n```\nimport {\n  ToggleGroup,\n  ToggleGroupItem,\n  ToggleGroupSeparator,\n} from \"@/components/ui/toggle-group\";\n```\n\n`import { Field, FieldDescription, FieldError, FieldLabel } from \"@/components/ui/field\";`\n\n`import { Form } from \"@/components/ui/form\";`\n\n`import { Button } from \"@/components/ui/button\";`\n\n`import { Label } from \"@/components/ui/label\";`\n\n`import { AlignLeftIcon, AlignCenterIcon, AlignRightIcon } from \"lucide-react\";`\n\n## Public API\n\n- `ToggleGroup` accepts single or multiple selection, controlled or uncontrolled value arrays, orientation, disabled state, Button-style size and Toggle variant.\n- Each `ToggleGroupItem` requires a unique `value` and inherits group size and variant unless explicitly overridden.\n- `ToggleGroupSeparator` defaults to `orientation=\"vertical\"`; set `orientation=\"horizontal\"` inside a vertical group.\n- Use `multiple` for several active items; omit it for one active value.\n\n## Public Motion\n\n- Pressed background, border and focus feedback use the native Toggle motion contract without changing group measurements.\n- Reduced motion keeps selection immediate and removes non-essential feedback transition.\n\n## Public Accessibility\n\n- Give the group an accessible name and every icon-only item its own `aria-label`.\n- Keep single- and multi-select semantics aligned with the actual state model.\n- Pair invalid state with visible error text when the group participates in a form.\n\n## Public Agent Guidance\n\n- Use Radio Group when explicit form-choice semantics are more important than compact toolbar presentation.\n- Match separator orientation to the group direction.\n- Do not build independent Toggle buttons when their values are meant to share one group state.\n\n## Public Links\n\n- [Documentation and preview](https://ui.siteplane.io/docs/components/actions/toggle-group)\n\n- [Registry source](https://ui.siteplane.io/r/toggle-group.json)\n",
  "meta": {
    "siteplane:agentContractVersion": 1,
    "siteplane:docs": "https://ui.siteplane.io/docs/components/actions/toggle-group",
    "siteplane:releaseVersion": "0.1.1",
    "siteplane:sourceOwned": true
  },
  "devDependencies": []
}
