{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"toggle-group","type":"registry:ui","title":"Toggle Group","description":"A row of toggle buttons – several on at once or one that can be cleared, labels or icons, a tray or separate chips, one tab stop with arrow keys","dependencies":[],"files":[{"path":"registry/toggle-group/toggle-group.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from \"react\";\n\n/*\n * Toggle Group – a row of toggle buttons, in a single self-contained file.\n * type=\"multiple\" (the default): any number can be on – filters, formatting, visible columns.\n * type=\"single\": at most one is on, and pressing it again turns it off – unlike the Segmented Control, which always\n * keeps one picked. \"tray\" (the default) sits them in a light-gray track like the Segmented Control, pressed ones\n * raised as white pills; \"buttons\" are separate white chips with an edge that wrap onto a new line when there are many.\n * A pressed item's fill grows in from its middle. One tab stop for the whole group; arrow keys move between the items,\n * Space or Enter flips one.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type ToggleGroupItem<T extends string = string> = {\n  value: T;\n  label?: ReactNode;\n  icon?: ReactNode;\n  /** Accessible name and tooltip – needed when the item only shows an icon */\n  ariaLabel?: string;\n  disabled?: boolean;\n};\n\ntype Shared<T extends string> = {\n  items: ToggleGroupItem<T>[];\n  /** sm 26 · md 30 · lg 36 high – bigger on touch screens */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** \"tray\" = a light-gray track, pressed items raised as white pills · \"buttons\" = separate chips with an edge that wrap */\n  variant?: \"buttons\" | \"tray\";\n  /** Disables every item */\n  disabled?: boolean;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  className?: string;\n};\n\nexport type ToggleGroupProps<T extends string = string> = Shared<T> &\n  (\n    | {\n        /** Any number of items can be on */\n        type?: \"multiple\";\n        value?: T[];\n        defaultValue?: T[];\n        onChange?: (value: T[]) => void;\n      }\n    | {\n        /** At most one item is on; pressing it again turns it off (null) */\n        type: \"single\";\n        value?: T | null;\n        defaultValue?: T | null;\n        onChange?: (value: T | null) => void;\n      }\n  );\n\nexport function ToggleGroup<T extends string = string>(props: ToggleGroupProps<T>) {\n  const {\n    items: rawItems,\n    size: sizeProp = \"md\",\n    variant: variantProp = \"tray\",\n    disabled = false,\n    \"aria-label\": ariaLabel,\n    \"aria-labelledby\": ariaLabelledby,\n    className,\n  } = props;\n  const single = props.type === \"single\";\n\n  // Gaps and repeated values are dropped – one value, one item\n  const items = useMemo(() => {\n    const seen = new Set<string>();\n    return (Array.isArray(rawItems) ? rawItems : []).filter((it): it is ToggleGroupItem<T> => {\n      if (!it || it.value == null || seen.has(String(it.value))) return false;\n      seen.add(String(it.value));\n      return true;\n    });\n  }, [rawItems]);\n  // Unknown sizes and variants fall back to the defaults\n  const size = sizeProp === \"sm\" || sizeProp === \"lg\" ? sizeProp : \"md\";\n  const variant = variantProp === \"buttons\" ? \"buttons\" : \"tray\";\n\n  const [inner, setInner] = useState<T[]>(() => toList(props.defaultValue));\n  const controlled = props.value !== undefined;\n  const on = controlled ? toList(props.value) : inner;\n\n  const group = useRef<HTMLDivElement>(null);\n  const [focused, setFocused] = useState<T | null>(null);\n\n  const toggle = (v: T) => {\n    const item = items.find((it) => it.value === v);\n    if (!item || item.disabled || disabled) return;\n    const pressed = on.includes(v);\n    // Multiple keeps the items' order, whatever order they were pressed in\n    const next = single ? (pressed ? [] : [v]) : items.map((it) => it.value).filter((x) => (x === v ? !pressed : on.includes(x)));\n    if (!controlled) setInner(next);\n    if (props.type === \"single\") props.onChange?.(next[0] ?? null);\n    else props.onChange?.(next);\n  };\n\n  const enabled = items.filter((it) => !it.disabled && !disabled);\n  // One tab stop: the item last focused, else the first one that's on, else the first one\n  const stop =\n    enabled.find((it) => it.value === focused)?.value ??\n    enabled.find((it) => on.includes(it.value))?.value ??\n    enabled[0]?.value;\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (!enabled.length || e.altKey || e.ctrlKey || e.metaKey) return;\n    const buttons = Array.from(group.current?.querySelectorAll<HTMLButtonElement>(\":scope > .ui-tgl__item:not(:disabled)\") ?? []);\n    const at = buttons.indexOf(document.activeElement as HTMLButtonElement);\n    if (at < 0) return;\n    // In right-to-left pages the left arrow goes on to the next item\n    const rtl = group.current ? getComputedStyle(group.current).direction === \"rtl\" : false;\n    const fwd = rtl ? \"ArrowLeft\" : \"ArrowRight\";\n    const back = rtl ? \"ArrowRight\" : \"ArrowLeft\";\n    let to = -1;\n    if (e.key === fwd || e.key === \"ArrowDown\") to = (at + 1) % buttons.length;\n    else if (e.key === back || e.key === \"ArrowUp\") to = (at - 1 + buttons.length) % buttons.length;\n    else if (e.key === \"Home\") to = 0;\n    else if (e.key === \"End\") to = buttons.length - 1;\n    if (to < 0) return;\n    e.preventDefault();\n    buttons[to].focus();\n  };\n\n  return (\n    <div\n      ref={group}\n      role=\"group\"\n      aria-label={ariaLabel}\n      aria-labelledby={ariaLabelledby}\n      aria-disabled={disabled || undefined}\n      className={[\"ui-tgl\", className].filter(Boolean).join(\" \")}\n      data-size={size}\n      data-variant={variant}\n      data-disabled={disabled || undefined}\n      onKeyDown={onKeyDown}\n    >\n      {/* React 19 hoists this into <head> once, no matter how many groups render */}\n      <style href=\"ui-toggle-group\" precedence=\"default\">\n        {css}\n      </style>\n      {items.map((it) => {\n        const pressed = on.includes(it.value);\n        // Without a label or icon the value names the item\n        const label = it.label ?? (it.icon ? null : String(it.value));\n        const iconOnly = label == null;\n        return (\n          <button\n            key={String(it.value)}\n            type=\"button\"\n            className=\"ui-tgl__item\"\n            aria-pressed={pressed}\n            aria-label={it.ariaLabel}\n            title={iconOnly ? it.ariaLabel : undefined}\n            data-icon-only={iconOnly || undefined}\n            disabled={it.disabled || disabled}\n            tabIndex={it.value === stop ? 0 : -1}\n            onFocus={() => setFocused(it.value)}\n            onClick={() => toggle(it.value)}\n          >\n            {it.icon != null && (\n              <span className=\"ui-tgl__icon\" aria-hidden=\"true\">\n                {it.icon}\n              </span>\n            )}\n            {label != null && (\n              <span className=\"ui-tgl__label\" dir=\"auto\">\n                {label}\n              </span>\n            )}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\n/** Value or values → a list; null, undefined and gaps drop out */\nfunction toList<T extends string>(v: T | T[] | null | undefined): T[] {\n  if (Array.isArray(v)) return v.filter((x) => x != null);\n  return v == null ? [] : [v];\n}\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors as variables – override them on .ui-tgl (see Theming in the docs) */\n.ui-tgl {\n  --ui-tgl-tray: var(--aui-subtle, #f0f0f0);\n  --ui-tgl-pill: var(--aui-surface, #ffffff);\n  --ui-tgl-pill-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.08), 0 2px 8px -2px rgba(0, 0, 0, 0.06);\n  --ui-tgl-line: var(--aui-border, #e5e5e5);\n  --ui-tgl-line-strong: #d4d4d4;\n  --ui-tgl-chip: var(--aui-surface, #ffffff);\n  --ui-tgl-fill: #f0f0f0;\n  --ui-tgl-wash: rgba(0, 0, 0, 0.04);\n  --ui-tgl-ink: var(--aui-ink, #171717);\n  --ui-tgl-text: var(--aui-muted, #737373);\n  --ui-tgl-hover-text: var(--aui-ink, #404040);\n  --ui-tgl-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-tgl-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n  /* Size: md */\n  --ui-tgl-h: 30px;\n  --ui-tgl-px: 12px;\n  --ui-tgl-fs: 13.5px;\n  --ui-tgl-icon-size: 15px;\n  --ui-tgl-gap: 8px;\n  --ui-tgl-pad: 3px;\n  --ui-tgl-r: calc(var(--aui-radius, 10px) + 1px);\n\n  box-sizing: border-box;\n  display: inline-flex;\n  flex-wrap: wrap;\n  align-items: center;\n  gap: var(--ui-tgl-gap);\n  max-width: 100%;\n  min-width: 0;\n  vertical-align: middle;\n  font-family: var(--aui-font, \"Inter\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: var(--ui-tgl-fs);\n  font-weight: 500;\n  line-height: 20px;\n  color: var(--ui-tgl-text);\n  -webkit-font-smoothing: antialiased;\n}\n.ui-tgl[data-size=\"sm\"] { --ui-tgl-h: 26px; --ui-tgl-px: 10px; --ui-tgl-fs: 12.5px; --ui-tgl-icon-size: 14px; --ui-tgl-gap: 6px; --ui-tgl-pad: 2px; --ui-tgl-r: calc(var(--aui-radius, 10px) - 1px); }\n.ui-tgl[data-size=\"lg\"] { --ui-tgl-h: 36px; --ui-tgl-px: 16px; --ui-tgl-fs: 14px; --ui-tgl-icon-size: 16px; --ui-tgl-pad: 4px; --ui-tgl-r: calc(var(--aui-radius, 10px) + 3px); }\n.ui-tgl[data-disabled] { opacity: 0.5; }\n.ui-tgl *, .ui-tgl *::before, .ui-tgl *::after { box-sizing: border-box; }\n/* Nothing to show – no empty track */\n.ui-tgl:not(:has(> .ui-tgl__item)) { display: none; }\n\n/* An item: the pressed fill is its own layer, so it can grow in from the middle */\n.ui-tgl__item {\n  position: relative;\n  display: inline-flex;\n  flex: none;\n  align-items: center;\n  justify-content: center;\n  gap: 6px;\n  max-width: 100%;\n  min-width: var(--ui-tgl-h);\n  height: var(--ui-tgl-h);\n  margin: 0;\n  padding: 0 var(--ui-tgl-px);\n  border: 0;\n  border-radius: calc(var(--ui-tgl-r) - var(--ui-tgl-pad));\n  background: transparent;\n  color: inherit;\n  font: inherit;\n  white-space: nowrap;\n  cursor: pointer;\n  -webkit-user-select: none;\n  user-select: none;\n  -webkit-tap-highlight-color: transparent;\n  transition: color 200ms ease, background-color 160ms ease, box-shadow 160ms ease, transform 200ms var(--ui-tgl-ease);\n}\n.ui-tgl__item::before {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  background: var(--ui-tgl-pill);\n  box-shadow: var(--ui-tgl-pill-shadow);\n  opacity: 0;\n  transform: scale(0.86);\n  pointer-events: none;\n  transition: opacity 160ms ease, transform 320ms var(--ui-tgl-ease);\n}\n.ui-tgl__item[aria-pressed=\"true\"] { color: var(--ui-tgl-ink); }\n.ui-tgl__item[aria-pressed=\"true\"]::before { opacity: 1; transform: none; }\n.ui-tgl__item[data-icon-only] { padding: 0; width: var(--ui-tgl-h); }\n.ui-tgl__item:active:not(:disabled) { transform: scale(0.96); }\n.ui-tgl__item:disabled { opacity: 0.4; cursor: not-allowed; }\n.ui-tgl[data-disabled] .ui-tgl__item:disabled { opacity: 1; }\n.ui-tgl__item:focus-visible { outline: 2px solid var(--ui-tgl-focus); outline-offset: 2px; }\n@media (hover: hover) {\n  .ui-tgl__item:not([aria-pressed=\"true\"]):not(:disabled):hover { color: var(--ui-tgl-hover-text); }\n}\n\n.ui-tgl__icon, .ui-tgl__label { position: relative; z-index: 1; }\n.ui-tgl__icon { display: grid; flex: none; place-items: center; width: var(--ui-tgl-icon-size); height: var(--ui-tgl-icon-size); overflow: hidden; }\n.ui-tgl__icon svg, .ui-tgl__icon img { display: block; width: 100%; height: 100%; object-fit: contain; }\n/* Long labels end in … instead of pushing the row wider than its container */\n.ui-tgl__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }\n\n/* Tray (default): the light-gray track of the Segmented Control; it scrolls sideways if it doesn't fit */\n.ui-tgl[data-variant=\"tray\"] {\n  flex-wrap: nowrap;\n  gap: 0;\n  padding: var(--ui-tgl-pad);\n  overflow-x: auto;\n  border-radius: var(--ui-tgl-r);\n  background: var(--ui-tgl-tray);\n  scrollbar-width: none;\n}\n.ui-tgl[data-variant=\"tray\"]::-webkit-scrollbar { display: none; }\n.ui-tgl[data-variant=\"tray\"] .ui-tgl__item:focus-visible { outline-offset: -2px; }\n\n/* Buttons: separate white chips with an edge; pressed ones sink into a gray fill. Many of them wrap */\n.ui-tgl[data-variant=\"buttons\"] .ui-tgl__item {\n  border-radius: calc(var(--ui-tgl-r) - 2px);\n  background: var(--ui-tgl-chip);\n  box-shadow: inset 0 0 0 1px var(--ui-tgl-line), 0 1px 2px rgba(0, 0, 0, 0.04);\n}\n.ui-tgl[data-variant=\"buttons\"] .ui-tgl__item::before { background: var(--ui-tgl-fill); box-shadow: inset 0 0 0 1px var(--ui-tgl-line-strong); }\n.ui-tgl[data-variant=\"buttons\"] .ui-tgl__item[aria-pressed=\"true\"] { box-shadow: none; }\n@media (hover: hover) {\n  .ui-tgl[data-variant=\"buttons\"] .ui-tgl__item:not([aria-pressed=\"true\"]):not(:disabled):hover { background: var(--aui-subtle, #fafafa); box-shadow: inset 0 0 0 1px var(--ui-tgl-line-strong), 0 1px 2px rgba(0, 0, 0, 0.04); }\n}\n\n/* Touch screens: every item at least 36px tall */\n@media (pointer: coarse) {\n  .ui-tgl[data-size] { --ui-tgl-h: 36px; }\n  .ui-tgl[data-size=\"lg\"] { --ui-tgl-h: 40px; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .ui-tgl__item, .ui-tgl__item::before { transition: none; }\n  .ui-tgl__item:active:not(:disabled) { transform: none; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-tgl {\n  --ui-tgl-tray: var(--aui-subtle, #111113);\n  --ui-tgl-pill: var(--aui-surface, #26262a);\n  --ui-tgl-pill-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06), 0 1px 2px rgba(0, 0, 0, 0.4);\n  --ui-tgl-line: var(--aui-border, #2a2a2e);\n  --ui-tgl-line-strong: #3a3a3f;\n  --ui-tgl-chip: var(--aui-surface, #161618);\n  --ui-tgl-fill: #26262a;\n  --ui-tgl-wash: rgba(255, 255, 255, 0.05);\n  --ui-tgl-ink: var(--aui-ink, #ededed);\n  --ui-tgl-text: var(--aui-muted, #8b8b92);\n  --ui-tgl-hover-text: var(--aui-ink, #c8c8cc);\n  --ui-tgl-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n}\n:where(.dark, [data-theme=\"dark\"]) .ui-tgl[data-variant=\"buttons\"] .ui-tgl__item { box-shadow: inset 0 0 0 1px var(--ui-tgl-line); }\n`;\n"}]}