{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"toggle-group-clean","type":"registry:ui","title":"Toggle Group (Clean)","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-clean/toggle-group-clean.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from \"react\";\n\n/*\n * Toggle Group (Clean) – 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. Clean style: \"buttons\" (the default) are separate quiet chips with a hairline that wrap onto a\n * new line when there are many; \"tray\" sits them in a compact light-gray track, pressed ones raised in white.\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 24 · md 28 · lg 32 high – bigger on touch screens */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** \"buttons\" = separate chips with a hairline that wrap · \"tray\" = a compact light-gray track, pressed items raised */\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 = \"buttons\",\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 === \"tray\" ? \"tray\" : \"buttons\";\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-tglc__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-tglc\", 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-clean\" 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-tglc__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-tglc__icon\" aria-hidden=\"true\">\n                {it.icon}\n              </span>\n            )}\n            {label != null && (\n              <span className=\"ui-tglc__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-tglc (see Theming in the docs) */\n.ui-tglc {\n  --ui-tglc-ink: var(--aui-ink, rgba(0, 0, 0, 0.88));\n  --ui-tglc-text: var(--aui-muted, rgba(0, 0, 0, 0.61));\n  --ui-tglc-icon: var(--aui-muted, rgba(0, 0, 0, 0.447));\n  --ui-tglc-line: var(--aui-border, rgba(0, 0, 0, 0.06));\n  --ui-tglc-wash: var(--aui-subtle, rgba(0, 0, 0, 0.03));\n  --ui-tglc-fill: rgba(0, 0, 0, 0.06);\n  --ui-tglc-track: var(--aui-subtle, rgba(0, 0, 0, 0.04));\n  --ui-tglc-card: var(--aui-surface, #ffffff);\n  --ui-tglc-card-shadow: 0 0 0 0.5px rgba(0, 0, 0, 0.06), 0 1px 1px -1px rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.05);\n  --ui-tglc-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-tglc-ease: cubic-bezier(0.25, 0.9, 0.3, 1);\n  /* Size: md – 28px chips, 4px 10px */\n  --ui-tglc-h: 28px;\n  --ui-tglc-px: 10px;\n  --ui-tglc-fs: 13px;\n  --ui-tglc-lh: 20px;\n  --ui-tglc-icon-size: 16px;\n  --ui-tglc-gap: 8px;\n  --ui-tglc-pad: 2px;\n  --ui-tglc-r: var(--aui-radius, 6px);\n\n  box-sizing: border-box;\n  display: inline-flex;\n  flex-wrap: wrap;\n  align-items: center;\n  gap: var(--ui-tglc-gap);\n  max-width: 100%;\n  min-width: 0;\n  vertical-align: middle;\n  font-family: var(--aui-font, \"Inter\", \"InterVariable\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: var(--ui-tglc-fs);\n  font-weight: 500;\n  line-height: var(--ui-tglc-lh);\n  color: var(--ui-tglc-text);\n  -webkit-font-smoothing: antialiased;\n}\n.ui-tglc[data-size=\"sm\"] { --ui-tglc-h: 24px; --ui-tglc-px: 8px; --ui-tglc-fs: 12px; --ui-tglc-lh: 16px; --ui-tglc-icon-size: 14px; --ui-tglc-gap: 6px; }\n.ui-tglc[data-size=\"lg\"] { --ui-tglc-h: 32px; --ui-tglc-px: 12px; }\n.ui-tglc[data-disabled] { opacity: 0.5; }\n.ui-tglc *, .ui-tglc *::before, .ui-tglc *::after { box-sizing: border-box; }\n/* Nothing to show – no empty track */\n.ui-tglc:not(:has(> .ui-tglc__item)) { display: none; }\n\n/* An item: the fill is its own layer, so it can grow in from the middle */\n.ui-tglc__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-tglc-h);\n  height: var(--ui-tglc-h);\n  margin: 0;\n  padding: 0 var(--ui-tglc-px);\n  border: 0;\n  border-radius: var(--ui-tglc-r);\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 90ms ease, background-color 90ms ease, transform 160ms var(--ui-tglc-ease);\n}\n.ui-tglc__item::before {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  background: var(--ui-tglc-fill);\n  opacity: 0;\n  transform: scale(0.88);\n  pointer-events: none;\n  transition: opacity 120ms ease, transform 200ms var(--ui-tglc-ease);\n}\n.ui-tglc__item[aria-pressed=\"true\"] { color: var(--ui-tglc-ink); }\n.ui-tglc__item[aria-pressed=\"true\"]::before { opacity: 1; transform: none; }\n.ui-tglc__item[data-icon-only] { padding: 0; width: var(--ui-tglc-h); }\n.ui-tglc__item:active:not(:disabled) { transform: scale(0.96); }\n.ui-tglc__item:disabled { opacity: 0.4; cursor: not-allowed; }\n.ui-tglc[data-disabled] .ui-tglc__item:disabled { opacity: 1; }\n.ui-tglc__item:focus-visible { outline: 2px solid var(--ui-tglc-focus); outline-offset: 2px; }\n@media (hover: hover) {\n  .ui-tglc__item:not([aria-pressed=\"true\"]):not(:disabled):hover { background-color: var(--ui-tglc-wash); color: var(--ui-tglc-ink); }\n}\n\n.ui-tglc__icon, .ui-tglc__label { position: relative; z-index: 1; }\n.ui-tglc__icon { display: grid; flex: none; place-items: center; width: var(--ui-tglc-icon-size); height: var(--ui-tglc-icon-size); overflow: hidden; color: var(--ui-tglc-icon); transition: color 90ms ease; }\n.ui-tglc__icon svg, .ui-tglc__icon img { display: block; width: 100%; height: 100%; object-fit: contain; }\n.ui-tglc__item[aria-pressed=\"true\"] .ui-tglc__icon { color: var(--ui-tglc-ink); }\n@media (hover: hover) {\n  .ui-tglc__item:not(:disabled):hover .ui-tglc__icon { color: var(--ui-tglc-ink); }\n}\n/* Long labels end in … instead of pushing the row wider than its container */\n.ui-tglc__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }\n\n/* Buttons (default): separate chips with a hairline; many of them wrap onto the next line */\n.ui-tglc[data-variant=\"buttons\"] .ui-tglc__item { box-shadow: inset 0 0 0 0.5px var(--ui-tglc-line); }\n\n/* Tray: a compact light-gray track; pressed items are raised in white */\n.ui-tglc[data-variant=\"tray\"] {\n  flex-wrap: nowrap;\n  gap: 0;\n  padding: var(--ui-tglc-pad);\n  overflow-x: auto;\n  border-radius: calc(var(--ui-tglc-r) + var(--ui-tglc-pad));\n  background: var(--ui-tglc-track);\n  scrollbar-width: none;\n}\n.ui-tglc[data-variant=\"tray\"]::-webkit-scrollbar { display: none; }\n.ui-tglc[data-variant=\"tray\"] .ui-tglc__item { --ui-tglc-h-in: calc(var(--ui-tglc-h) - var(--ui-tglc-pad) * 2); height: var(--ui-tglc-h-in); min-width: var(--ui-tglc-h-in); }\n.ui-tglc[data-variant=\"tray\"] .ui-tglc__item[data-icon-only] { width: var(--ui-tglc-h-in); }\n.ui-tglc[data-variant=\"tray\"] .ui-tglc__item::before { background: var(--ui-tglc-card); box-shadow: var(--ui-tglc-card-shadow); }\n.ui-tglc[data-variant=\"tray\"] .ui-tglc__item:focus-visible { outline-offset: -2px; }\n@media (hover: hover) {\n  .ui-tglc[data-variant=\"tray\"] .ui-tglc__item:not([aria-pressed=\"true\"]):not(:disabled):hover { background-color: transparent; }\n}\n\n/* Touch screens: every item at least 36px tall */\n@media (pointer: coarse) {\n  .ui-tglc[data-size] { --ui-tglc-h: 36px; }\n  .ui-tglc[data-size=\"lg\"] { --ui-tglc-h: 40px; }\n  .ui-tglc[data-variant=\"tray\"][data-size] { --ui-tglc-h: 40px; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .ui-tglc__item, .ui-tglc__item::before, .ui-tglc__icon { transition: none; }\n  .ui-tglc__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-tglc {\n  --ui-tglc-ink: var(--aui-ink, rgba(255, 255, 255, 0.9));\n  --ui-tglc-text: var(--aui-muted, rgba(255, 255, 255, 0.62));\n  --ui-tglc-icon: var(--aui-muted, rgba(255, 255, 255, 0.46));\n  --ui-tglc-line: var(--aui-border, rgba(255, 255, 255, 0.08));\n  --ui-tglc-wash: var(--aui-subtle, rgba(255, 255, 255, 0.04));\n  --ui-tglc-fill: rgba(255, 255, 255, 0.08);\n  --ui-tglc-track: var(--aui-subtle, rgba(255, 255, 255, 0.05));\n  --ui-tglc-card: var(--aui-surface, #2c2c2f);\n  --ui-tglc-card-shadow: 0 0 0 0.5px rgba(255, 255, 255, 0.1), 0 1px 2px rgba(0, 0, 0, 0.4);\n  --ui-tglc-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n}\n`;\n"}]}