{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"apple-mail-tabs","title":"Apple Mail Tabs","author":"CoDesign-Spa27","description":"A colorful, horizontally scrollable mail category selector with compact inactive tabs and an expanded active tab.","dependencies":["lucide-react","motion"],"files":[{"path":"components/ui-components/apple-mail-tabs.tsx","content":"\"use client\";\n\nimport {\n  Archive,\n  type LucideIcon,\n  Megaphone,\n  MessageSquareText,\n  ShoppingCart,\n  UserRound,\n} from \"lucide-react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ComponentPropsWithoutRef,\n  type KeyboardEvent,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type AppleMailTab<T extends string = string> = {\n  activeClassName?: string;\n  disabled?: boolean;\n  icon: LucideIcon;\n  label: string;\n  value: T;\n};\n\nexport type AppleMailTabsProps<T extends string = string> = Omit<\n  ComponentPropsWithoutRef<\"div\">,\n  \"defaultValue\" | \"onChange\"\n> & {\n  activeTabClassName?: string;\n  \"aria-label\"?: string;\n  defaultValue?: T;\n  onValueChange?: (value: T) => void;\n  tabClassName?: string;\n  tabs?: readonly AppleMailTab<T>[];\n  value?: T;\n};\n\nexport const appleMailTabs = [\n  {\n    activeClassName: \"bg-[#0a84ff] text-white\",\n    icon: UserRound,\n    label: \"Primary\",\n    value: \"primary\",\n  },\n  {\n    activeClassName: \"bg-[#30d158] text-[#09280f]\",\n    icon: ShoppingCart,\n    label: \"Transactions\",\n    value: \"transactions\",\n  },\n  {\n    activeClassName: \"bg-[#bf5af2] text-white\",\n    icon: MessageSquareText,\n    label: \"Updates\",\n    value: \"updates\",\n  },\n  {\n    activeClassName: \"bg-[#ff9f0a] text-[#2b1900]\",\n    icon: Megaphone,\n    label: \"Promotions\",\n    value: \"promotions\",\n  },\n  {\n    activeClassName: \"bg-[#ff375f] text-white\",\n    icon: Archive,\n    label: \"All Mail\",\n    value: \"all-mail\",\n  },\n] as const satisfies readonly AppleMailTab[];\n\nexport type AppleMailTabValue = (typeof appleMailTabs)[number][\"value\"];\n\nconst CLOSED_WIDTH = 102;\n\nconst ICON_SIZE = 28;\n\nconst CONTENT_GAP = 14;\n\nconst ICON_CENTER = CLOSED_WIDTH / 2;\n\nconst LABEL_LEFT = ICON_CENTER + ICON_SIZE / 2 + CONTENT_GAP;\n\nconst PILL_TRANSITION = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 36,\n  mass: 0.8,\n} as const;\n\nconst LABEL_TRANSITION = {\n  duration: 0.22,\n  ease: [0.23, 1, 0.32, 1],\n} as const;\n\nfunction getInitialValue<T extends string>(tabs: readonly AppleMailTab<T>[], defaultValue?: T) {\n  if (defaultValue && tabs.some((tab) => tab.value === defaultValue && !tab.disabled)) {\n    return defaultValue;\n  }\n\n  return tabs.find((tab) => !tab.disabled)?.value;\n}\n\nexport function AppleMailTabs<T extends string = AppleMailTabValue>({\n  \"aria-label\": ariaLabel = \"Mail categories\",\n  activeTabClassName,\n  className,\n  defaultValue,\n  onValueChange,\n  tabClassName,\n  tabs = appleMailTabs as unknown as readonly AppleMailTab<T>[],\n  value,\n  ...props\n}: AppleMailTabsProps<T>) {\n  const reduceMotion = useReducedMotion();\n\n  const [uncontrolledValue, setUncontrolledValue] = useState<T | undefined>(() =>\n    getInitialValue(tabs, defaultValue ?? (\"updates\" as T)),\n  );\n\n  const labelRefs = useRef(new Map<T, HTMLSpanElement>());\n\n  const [labelWidths, setLabelWidths] = useState<Map<T, number>>(() => new Map());\n\n  // Re-measure labels when callers replace the tab definitions.\n  // biome-ignore lint/correctness/useExhaustiveDependencies: The rendered labels change with tabs.\n  useLayoutEffect(() => {\n    const widths = new Map<T, number>();\n\n    labelRefs.current.forEach((element, key) => {\n      widths.set(key, element.getBoundingClientRect().width);\n    });\n\n    setLabelWidths(widths);\n  }, [tabs]);\n\n  const selectedValue = value ?? uncontrolledValue;\n\n  const selectedTab =\n    tabs.find((tab) => tab.value === selectedValue && !tab.disabled) ??\n    tabs.find((tab) => !tab.disabled);\n\n  if (!tabs.length) {\n    return null;\n  }\n\n  function selectTab(tab: AppleMailTab<T>) {\n    if (tab.disabled || tab.value === selectedTab?.value) {\n      return;\n    }\n\n    if (value === undefined) {\n      setUncontrolledValue(tab.value);\n    }\n\n    onValueChange?.(tab.value);\n  }\n\n  function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>, tabIndex: number) {\n    if (![\"ArrowLeft\", \"ArrowRight\", \"Home\", \"End\"].includes(event.key)) {\n      return;\n    }\n\n    event.preventDefault();\n\n    const enabledIndexes = tabs.reduce<number[]>((indexes, tab, index) => {\n      if (!tab.disabled) {\n        indexes.push(index);\n      }\n\n      return indexes;\n    }, []);\n\n    if (!enabledIndexes.length) {\n      return;\n    }\n\n    const currentPosition = enabledIndexes.indexOf(tabIndex);\n\n    let nextIndex = enabledIndexes[0];\n\n    if (event.key === \"Home\") {\n      nextIndex = enabledIndexes[0];\n    }\n\n    if (event.key === \"End\") {\n      nextIndex = enabledIndexes.at(-1) ?? enabledIndexes[0];\n    }\n\n    if (event.key === \"ArrowLeft\") {\n      nextIndex =\n        enabledIndexes[(currentPosition - 1 + enabledIndexes.length) % enabledIndexes.length];\n    }\n\n    if (event.key === \"ArrowRight\") {\n      nextIndex = enabledIndexes[(currentPosition + 1) % enabledIndexes.length];\n    }\n\n    const nextTab = tabs[nextIndex];\n\n    const nextButton = event.currentTarget.parentElement?.children.item(nextIndex);\n\n    if (nextTab && nextButton instanceof HTMLButtonElement) {\n      nextButton.focus();\n      selectTab(nextTab);\n    }\n  }\n\n  return (\n    <div className={cn(\"no-scrollbar w-full overflow-x-auto\", className)} {...props}>\n      <div\n        role=\"tablist\"\n        aria-label={ariaLabel}\n        aria-orientation=\"horizontal\"\n        className=\"flex min-w-max items-center gap-3\"\n      >\n        {tabs.map((tab, tabIndex) => {\n          const Icon = tab.icon;\n\n          const isActive = tab.value === selectedTab?.value;\n\n          const labelWidth = labelWidths.get(tab.value) ?? 0;\n\n          const activeWidth = CLOSED_WIDTH + CONTENT_GAP + labelWidth;\n\n          return (\n            <motion.button\n              key={tab.value}\n              type=\"button\"\n              role=\"tab\"\n              aria-label={tab.label}\n              aria-selected={isActive}\n              disabled={tab.disabled}\n              tabIndex={isActive ? 0 : -1}\n              initial={false}\n              animate={{\n                width: isActive ? activeWidth : CLOSED_WIDTH,\n              }}\n              transition={\n                reduceMotion\n                  ? {\n                      duration: 0,\n                    }\n                  : PILL_TRANSITION\n              }\n              onClick={() => selectTab(tab)}\n              onKeyDown={(event) => handleKeyDown(event, tabIndex)}\n              className={cn(\n                \"relative h-[60px] shrink-0 overflow-hidden rounded-full outline-none\",\n\n                \"transition-[background-color,color] duration-200 ease-out\",\n\n                \"focus-visible:ring-2\",\n                \"focus-visible:ring-white/90\",\n                \"focus-visible:ring-offset-2\",\n                \"focus-visible:ring-offset-[#1d1d1f]\",\n\n                \"disabled:cursor-not-allowed\",\n                \"disabled:opacity-40\",\n\n                isActive\n                  ? \"bg-[#817bc5] text-white\"\n                  : [\n                      \"bg-[#28282a] text-[#9b9b9f]\",\n                      \"hover:bg-[#303033]\",\n                      \"hover:text-[#b8b8bc]\",\n                      \"active:bg-[#343437]\",\n                    ],\n\n                tabClassName,\n\n                isActive && tab.activeClassName,\n\n                isActive && activeTabClassName,\n              )}\n            >\n              <span\n                className=\"\n                    pointer-events-none\n                    absolute\n                    inset-y-0\n                    left-0\n                    flex\n                    w-[102px]\n                    items-center\n                    justify-center\n                  \"\n              >\n                <Icon aria-hidden=\"true\" className=\"size-7 shrink-0\" strokeWidth={2.5} />\n              </span>\n\n              <motion.span\n                ref={(element) => {\n                  if (element) {\n                    labelRefs.current.set(tab.value, element);\n                  } else {\n                    labelRefs.current.delete(tab.value);\n                  }\n                }}\n                aria-hidden={!isActive}\n                initial={false}\n                animate={{\n                  opacity: isActive ? 1 : 0,\n\n                  x: isActive ? 0 : 18,\n\n                  filter: isActive ? \"blur(0px)\" : \"blur(7px)\",\n                }}\n                transition={\n                  reduceMotion\n                    ? {\n                        duration: 0,\n                      }\n                    : {\n                        ...LABEL_TRANSITION,\n\n                        delay: isActive ? 0.04 : 0,\n                      }\n                }\n                style={{\n                  left: LABEL_LEFT,\n                }}\n                className=\"\n                    pointer-events-none\n                    absolute\n                    inset-y-0\n                    flex\n                    items-center\n                    whitespace-nowrap\n                    text-[26px]\n                    font-semibold\n                    tracking-[-0.02em]\n                  \"\n              >\n                {tab.label}\n              </motion.span>\n            </motion.button>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n","type":"registry:ui"}],"categories":["navigation","tabs","mobile"],"type":"registry:ui"}