{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "toggle-group",
  "title": "Toggle Group",
  "type": "registry:ui",
  "description": "Group of `Toggle` buttons that act as a single-select or multi-select control. Use for view-mode pickers (grid/list), text-format toolbars, and any \"pick one of N\" button bar.",
  "categories": [
    "action"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/toggle-group/toggle-group.ts",
      "content": "import { LitElement, css, html, nothing, type PropertyValues } from 'lit'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { toggleVariants, type ToggleVariants } from './toggle-group.variants'\n\ntype Variant = NonNullable<ToggleVariants['variant']>\ntype Size = NonNullable<ToggleVariants['size']>\n\ninterface Item {\n  el: UipToggleGroupItem\n  value: string\n  label?: string\n  disabled: boolean\n  variant?: Variant\n  size?: Size\n}\n\nconst EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'\n\n/**\n * <uip-toggle-group-item> — marks one item of a <uip-toggle-group>: `value`,\n * `disabled`, `aria-label`, optional per-item `variant` / `size` (the group's\n * win, like React's context). Its children (icon / text) are the item's label.\n *\n * It renders nothing of its own: the group renders the real <button> in its\n * shadow root and slots this element into it (manual slot assignment), so all\n * buttons, the sliding indicator and the roving focus live in ONE shadow root\n * and React's `group-data-*` / `first-of-type` classes keep working.\n */\nexport class UipToggleGroupItem extends LitElement {\n  // display: contents so the slotted icon/text lay out as the button's own flex children.\n  static styles = [tailwind, css`:host { display: contents; }`]\n\n  static properties = {\n    value: { reflect: true },\n    disabled: { type: Boolean, reflect: true },\n    variant: { reflect: true },\n    size: { reflect: true },\n  }\n\n  value = ''\n  disabled = false\n  variant?: Variant\n  size?: Size\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'toggle-group-item')\n  }\n\n  render() {\n    // React's `[&_svg]:…` item classes, re-expressed for a slotted <svg>.\n    return html`<slot\n      class=\"[&::slotted(svg)]:pointer-events-none [&::slotted(svg)]:shrink-0 [&::slotted(svg:not([class*='size-']))]:size-4\"\n    ></slot>`\n  }\n}\n\n/**\n * <uip-toggle-group> — the registry ToggleGroup + ToggleGroupItem (Radix toggle-group).\n *\n *   <uip-toggle-group type=\"single\" value=\"center\">\n *     <uip-toggle-group-item value=\"left\" aria-label=\"Align left\"><svg …/></uip-toggle-group-item>\n *     …\n *   </uip-toggle-group>\n *\n * Props (React names): `type` ('single' | 'multiple'), `value` / `default-value`\n * (string for single, string[] for multiple; attribute \"bold,italic\"), `variant`,\n * `size`, `spacing` (number, default 0), `animated` (sliding indicator for single,\n * default true; `animated=\"false\"` turns it off), `disabled`, `orientation`,\n * `roving-focus` (default true), `loop` (default true), `name`, `required`.\n *\n * Events (bubble, composed): `input`, `change`, and `value-change` (React\n * `onValueChange`; `detail: { value }`, value: string | string[]). Like Radix, clicking the pressed\n * item of a single group clears the value ('').\n *\n * Form-associated: submits `name=value` (single) or one `name=value` per pressed\n * item (multiple); `required` is invalid while nothing is pressed.\n */\nexport class UipToggleGroup extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions: ShadowRootInit = {\n    ...LitElement.shadowRootOptions,\n    delegatesFocus: true,\n    slotAssignment: 'manual',\n  }\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    type: { reflect: true },\n    value: {},\n    defaultValue: { attribute: 'default-value' },\n    variant: { reflect: true },\n    size: { reflect: true },\n    spacing: { type: Number },\n    animated: { converter: { fromAttribute: (v: string | null) => v !== 'false' } },\n    disabled: { type: Boolean, reflect: true },\n    orientation: { reflect: true },\n    rovingFocus: { attribute: 'roving-focus', converter: { fromAttribute: (v: string | null) => v !== 'false' } },\n    loop: { converter: { fromAttribute: (v: string | null) => v !== 'false' } },\n    name: { reflect: true },\n    required: { type: Boolean, reflect: true },\n    items: { state: true },\n    tabStop: { state: true },\n  }\n\n  type: 'single' | 'multiple' = 'single'\n  value?: string | string[]\n  defaultValue?: string | string[]\n  variant?: Variant\n  size?: Size\n  spacing = 0\n  animated = true\n  disabled = false\n  orientation?: 'horizontal' | 'vertical'\n  rovingFocus = true\n  loop = true\n  name?: string\n  required = false\n  private items: Item[] = []\n  private tabStop = -1\n\n  private initialValue: string | string[] = ''\n  private firstPosition = true\n  private internals = this.attachInternals()\n  private childObserver?: MutationObserver\n  private resizeObserver?: ResizeObserver\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'toggle-group')\n    this.readItems()\n    this.childObserver = new MutationObserver(() => this.readItems())\n    this.childObserver.observe(this, {\n      childList: true,\n      subtree: true,\n      attributes: true,\n      attributeFilter: ['value', 'disabled', 'aria-label', 'variant', 'size'],\n    })\n    this.resizeObserver = new ResizeObserver(() => this.updateIndicator())\n    this.firstPosition = true\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.childObserver?.disconnect()\n    this.resizeObserver?.disconnect()\n  }\n\n  private readItems() {\n    this.items = [...this.children]\n      .filter((el): el is UipToggleGroupItem => el.localName === 'uip-toggle-group-item')\n      .map((el) => ({\n        el,\n        value: el.getAttribute('value') ?? '',\n        label: el.getAttribute('aria-label') ?? undefined,\n        disabled: el.hasAttribute('disabled'),\n        variant: (el.getAttribute('variant') as Variant) ?? undefined,\n        size: (el.getAttribute('size') as Size) ?? undefined,\n      }))\n  }\n\n  /** The value as a list, whatever form it was given in. */\n  private get values(): string[] {\n    const v = this.value\n    if (Array.isArray(v)) return v\n    if (!v) return []\n    return this.type === 'multiple' ? v.split(',').map((s) => s.trim()).filter(Boolean) : [v]\n  }\n\n  private get indicatorActive() {\n    return this.animated !== false && this.type !== 'multiple'\n  }\n\n  protected willUpdate(changed: PropertyValues<this>) {\n    if (this.value === undefined) this.value = this.defaultValue ?? (this.type === 'multiple' ? [] : '')\n    if (!this.hasUpdated) this.initialValue = this.value\n    if (changed.has('value') || changed.has('name') || changed.has('required') || changed.has('type')) {\n      const vals = this.values\n      if (!this.name || !vals.length) this.internals.setFormValue(null)\n      else if (vals.length === 1) this.internals.setFormValue(vals[0])\n      else {\n        const fd = new FormData()\n        vals.forEach((v) => fd.append(this.name!, v))\n        this.internals.setFormValue(fd)\n      }\n      if (this.required && !vals.length) {\n        this.internals.setValidity({ valueMissing: true }, 'Please select an option.', this.buttons[0] ?? undefined)\n      } else this.internals.setValidity({})\n    }\n    if (changed.has('animated') || changed.has('type') || changed.has('size') || changed.has('spacing') || changed.has('variant')) {\n      this.firstPosition = true\n    }\n  }\n\n  protected updated() {\n    // Manual slot assignment: each item element goes into its own button.\n    const slots = this.renderRoot.querySelectorAll<HTMLSlotElement>('slot[data-item]')\n    slots.forEach((slot, i) => {\n      const el = this.items[i]?.el\n      if (el && slot.assignedNodes()[0] !== el) slot.assign(el)\n    })\n    this.resizeObserver?.disconnect()\n    if (this.indicatorActive) {\n      const root = this.renderRoot.querySelector('[data-slot=toggle-group]')\n      if (root) this.resizeObserver?.observe(root)\n      this.buttons.forEach((b) => this.resizeObserver?.observe(b))\n    }\n    this.updateIndicator()\n  }\n\n  // --- form callbacks -------------------------------------------------------\n  formResetCallback() {\n    this.value = this.initialValue\n  }\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  private get buttons() {\n    return [...(this.renderRoot?.querySelectorAll<HTMLButtonElement>('button[data-slot=toggle-group-item]') ?? [])]\n  }\n\n  /** React's updateIndicator: size/translate the pill onto the pressed item. */\n  private updateIndicator() {\n    const ind = this.renderRoot?.querySelector<HTMLElement>('[data-slot=toggle-group-indicator]')\n    const root = this.renderRoot?.querySelector<HTMLElement>('[data-slot=toggle-group]')\n    if (!ind || !root) return\n    const active = root.querySelector<HTMLElement>('[data-slot=\"toggle-group-item\"][data-state=\"on\"]')\n    if (!active) {\n      ind.style.opacity = '0'\n      return\n    }\n    const listRect = root.getBoundingClientRect()\n    const r = active.getBoundingClientRect()\n    const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches\n    Object.assign(ind.style, {\n      width: `${r.width}px`,\n      height: `${r.height}px`,\n      transform: `translate3d(${r.left - listRect.left + root.scrollLeft}px, ${r.top - listRect.top + root.scrollTop}px, 0)`,\n      borderRadius: getComputedStyle(active).borderRadius,\n      opacity: '1',\n      transition:\n        reduce || this.firstPosition\n          ? 'none'\n          : `transform 220ms ${EASE}, width 220ms ${EASE}, height 220ms ${EASE}, border-radius 220ms ${EASE}`,\n    })\n    this.firstPosition = false\n  }\n\n  private toggle(item: Item) {\n    if (this.disabled || item.disabled) return\n    const pressed = this.values.includes(item.value)\n    let next: string | string[]\n    if (this.type === 'multiple') {\n      next = pressed ? this.values.filter((v) => v !== item.value) : [...this.values, item.value]\n    } else {\n      next = pressed ? '' : item.value\n    }\n    this.value = next\n    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    this.dispatchEvent(\n      new CustomEvent('value-change', {\n        detail: { value: Array.isArray(next) ? [...next] : next },\n        bubbles: true,\n        composed: true,\n      }),\n    )\n  }\n\n  private isFocusable(i: number) {\n    return !this.disabled && !this.items[i]?.disabled\n  }\n\n  /** Radix RovingFocusGroup: the pressed item (or the first focusable) is the tab stop. */\n  private currentTabStop() {\n    if (this.tabStop >= 0 && this.isFocusable(this.tabStop)) return this.tabStop\n    const pressed = this.items.findIndex((it, i) => this.values.includes(it.value) && this.isFocusable(i))\n    if (pressed >= 0) return pressed\n    return this.items.findIndex((_, i) => this.isFocusable(i))\n  }\n\n  private onKeyDown(e: KeyboardEvent) {\n    if (!this.rovingFocus) return\n    const horizontalKeys = ['ArrowLeft', 'ArrowRight']\n    const verticalKeys = ['ArrowUp', 'ArrowDown']\n    if (this.orientation === 'vertical' && horizontalKeys.includes(e.key)) return\n    if (this.orientation === 'horizontal' && verticalKeys.includes(e.key)) return\n    const focusable = this.items.map((_, i) => i).filter((i) => this.isFocusable(i))\n    if (!focusable.length) return\n    const current = focusable.indexOf(this.buttons.indexOf(e.target as HTMLButtonElement))\n    let next: number\n    switch (e.key) {\n      case 'ArrowLeft':\n      case 'ArrowUp':\n        next = current <= 0 ? (this.loop ? focusable.length - 1 : 0) : current - 1\n        break\n      case 'ArrowRight':\n      case 'ArrowDown':\n        next = current >= focusable.length - 1 ? (this.loop ? 0 : focusable.length - 1) : current + 1\n        break\n      case 'Home':\n        next = 0\n        break\n      case 'End':\n        next = focusable.length - 1\n        break\n      default:\n        return\n    }\n    e.preventDefault()\n    this.tabStop = focusable[next]\n    this.buttons[focusable[next]]?.focus()\n  }\n\n  render() {\n    const vals = this.values\n    const indicator = this.indicatorActive\n    const tabStop = this.rovingFocus ? this.currentTabStop() : -1\n    return html`<div\n      part=\"root\"\n      data-uipkge=\"\"\n      data-slot=\"toggle-group\"\n      role=${this.type === 'multiple' ? 'toolbar' : 'radiogroup'}\n      dir=\"ltr\"\n      data-orientation=${this.rovingFocus ? (this.orientation ?? nothing) : nothing}\n      data-size=${this.size ?? nothing}\n      data-variant=${this.variant ?? nothing}\n      data-spacing=${this.spacing}\n      data-animated=${indicator ? 'true' : 'false'}\n      aria-orientation=${this.orientation ?? nothing}\n      style=${`--gap: ${this.spacing}`}\n      class=\"group/toggle-group relative flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs\"\n      @keydown=${this.onKeyDown}\n    >\n      ${indicator\n        ? html`<span\n            data-slot=\"toggle-group-indicator\"\n            aria-hidden=\"true\"\n            class=\"bg-accent pointer-events-none absolute top-0 left-0 z-0 shadow-xs will-change-transform opacity-0\"\n          ></span>`\n        : nothing}\n      ${this.items.map((it, i) => {\n        const pressed = vals.includes(it.value)\n        const disabled = this.disabled || it.disabled\n        const variant = this.variant || it.variant\n        const size = this.size || it.size\n        const single = this.type !== 'multiple'\n        return html`<button\n          part=\"item\"\n          type=\"button\"\n          data-uipkge=\"\"\n          data-slot=\"toggle-group-item\"\n          data-variant=${variant ?? nothing}\n          data-size=${size ?? nothing}\n          data-spacing=${this.spacing}\n          data-state=${pressed ? 'on' : 'off'}\n          ?data-disabled=${disabled}\n          data-orientation=${this.rovingFocus ? (this.orientation ?? nothing) : nothing}\n          role=${single ? 'radio' : nothing}\n          aria-checked=${single ? String(pressed) : nothing}\n          aria-pressed=${single ? nothing : String(pressed)}\n          aria-label=${it.label ?? nothing}\n          ?disabled=${disabled}\n          tabindex=${this.rovingFocus ? (i === tabStop ? 0 : -1) : nothing}\n          class=${cn(\n            toggleVariants({ variant, size }),\n            'relative z-10 w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',\n            'group-data-[animated=true]/toggle-group:data-[state=on]:bg-transparent group-data-[animated=true]/toggle-group:data-[state=on]:hover:bg-transparent',\n            'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first-of-type:rounded-l-md data-[spacing=0]:last-of-type:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first-of-type:border-l',\n          )}\n          @click=${() => this.toggle(it)}\n          @focus=${() => (this.tabStop = i)}\n        >\n          <slot data-item></slot>\n        </button>`\n      })}\n    </div>`\n  }\n}\n\ncustomElements.get('uip-toggle-group-item') || customElements.define('uip-toggle-group-item', UipToggleGroupItem)\ncustomElements.get('uip-toggle-group') || customElements.define('uip-toggle-group', UipToggleGroup)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-toggle-group': UipToggleGroup\n    'uip-toggle-group-item': UipToggleGroupItem\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/toggle-group/toggle-group.ts"
    },
    {
      "path": "packages/elements-lit/src/components/toggle-group/toggle-group.variants.ts",
      "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\n/**\n * Copy of packages/registry-react/components/toggle/toggle.variants.ts, which\n * React's toggle-group imports. Duplicated here so <uip-toggle-group> does not\n * depend on the separate <uip-toggle> element.\n */\nexport const toggleVariants = cva(\n  // Focus ring intentionally subtle: 1px ring on :focus-visible only, no\n  // border swap. Keyboard users still get a clear focused state; mouse\n  // users don't see a loud 3px halo after click (which lingers because\n  // the button retains focus). Matches the segmented-control density.\n  \"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:ring-ring/50 focus-visible:ring-1 outline-none transition-colors duration-200 whitespace-nowrap\",\n  {\n    variants: {\n      variant: {\n        default: 'bg-transparent',\n        outline: 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',\n      },\n      size: {\n        default: 'h-9 px-2 min-w-9',\n        sm: 'h-8 px-1.5 min-w-8',\n        lg: 'h-10 px-2.5 min-w-10',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  },\n)\n\nexport type ToggleVariants = VariantProps<typeof toggleVariants>\n",
      "type": "registry:file",
      "target": "src/uip/components/toggle-group/toggle-group.variants.ts"
    }
  ],
  "dependencies": [
    "class-variance-authority",
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
