{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "launchpad",
  "title": "Launchpad",
  "description": "A full-screen app launcher overlay with a search field, paged app grid and page dots.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "files": [
    {
      "path": "registry/tealmac/launchpad.tsx",
      "content": "// Adapted from Ballmac UI (https://github.com/vamsiy78/ballmac-ui), MIT License, Copyright (c) 2026 Ballmac.\n\"use client\"\n\nimport * as React from \"react\"\nimport { Search } from \"lucide-react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\nfunction useDirection(dir?: \"ltr\" | \"rtl\"): \"ltr\" | \"rtl\" {\n  const doc = React.useSyncExternalStore(\n    (cb) => {\n      const o = new MutationObserver(cb)\n      o.observe(document.documentElement, {\n        attributes: true,\n        attributeFilter: [\"dir\"],\n      })\n      return () => o.disconnect()\n    },\n    () => (document.documentElement.dir === \"rtl\" ? \"rtl\" : \"ltr\"),\n    () => \"ltr\" as const\n  )\n  return dir ?? doc\n}\n\ntype LaunchpadApp = {\n  /** Unique id. */\n  id: string\n  /** Name under the icon. */\n  name: string\n  /** The icon, usually an AppIcon. */\n  icon: React.ReactNode\n}\n\ntype LaunchpadProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The apps to show, in order. */\n  apps: LaunchpadApp[]\n  /** Whether the launcher is showing. */\n  open: boolean\n  /** Called when the launcher asks to close (Escape, or a click on the empty background). */\n  onClose?: () => void\n  /** Called when an app is chosen. */\n  onLaunch?: (app: LaunchpadApp) => void\n  /** Width of one app cell in pixels. */\n  cellWidth?: number\n  /** Height of one app cell in pixels. */\n  cellHeight?: number\n  /** Accessible name of the launcher. */\n  label?: string\n}\n\n/**\n * A full-screen app launcher: a frosted overlay with a search field, pages of apps and page dots.\n * It fills its positioned parent. Arrow keys move between apps and across pages, PageUp and PageDown turn pages,\n * and typing filters. Escape closes it.\n */\nfunction Launchpad({\n  apps,\n  open,\n  onClose,\n  onLaunch,\n  cellWidth = 120,\n  cellHeight = 112,\n  label,\n  className,\n  ...props\n}: LaunchpadProps) {\n  const dir = useDirection()\n  label ??= \"App launcher\"\n  const reduce = useReducedMotion()\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const searchRef = React.useRef<HTMLInputElement>(null)\n  const [size, setSize] = React.useState({ w: 800, h: 560 })\n  const [query, setQuery] = React.useState(\"\")\n  const [page, setPage] = React.useState(0)\n  const [focus, setFocus] = React.useState(0)\n  const wheel = React.useRef(0)\n\n  React.useEffect(() => {\n    const el = rootRef.current\n    if (!el || !open) return\n    const measure = () => setSize({ w: el.clientWidth, h: el.clientHeight })\n    measure()\n    const ro = new ResizeObserver(measure)\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [open])\n\n  React.useEffect(() => {\n    if (open) {\n      setQuery(\"\")\n      setPage(0)\n      setFocus(0)\n      const id = requestAnimationFrame(() => searchRef.current?.focus())\n      return () => cancelAnimationFrame(id)\n    }\n  }, [open])\n\n  const cols = Math.max(2, Math.min(8, Math.floor((size.w - 48) / cellWidth)))\n  const rows = Math.max(1, Math.min(5, Math.floor((size.h - 170) / cellHeight)))\n  const perPage = cols * rows\n  const shown = apps.filter(\n    (a) => !query || a.name.toLowerCase().includes(query.toLowerCase())\n  )\n  const pages = Math.max(1, Math.ceil(shown.length / perPage))\n  const current = Math.min(page, pages - 1)\n\n  function goPage(next: number) {\n    setPage(Math.min(pages - 1, Math.max(0, next)))\n  }\n  function focusApp(index: number) {\n    const i = Math.min(shown.length - 1, Math.max(0, index))\n    setFocus(i)\n    setPage(Math.floor(i / perPage))\n    requestAnimationFrame(() =>\n      rootRef.current\n        ?.querySelector<HTMLElement>(`[data-app-index=\"${i}\"]`)\n        ?.focus()\n    )\n  }\n\n  function onKeyDown(e: React.KeyboardEvent) {\n    if (e.key === \"Escape\") {\n      e.preventDefault()\n      onClose?.()\n    } else if (\n      e.key === \"PageDown\" ||\n      (e.key === (dir === \"rtl\" ? \"ArrowLeft\" : \"ArrowRight\") && e.ctrlKey)\n    ) {\n      e.preventDefault()\n      goPage(current + 1)\n    } else if (\n      e.key === \"PageUp\" ||\n      (e.key === (dir === \"rtl\" ? \"ArrowRight\" : \"ArrowLeft\") && e.ctrlKey)\n    ) {\n      e.preventDefault()\n      goPage(current - 1)\n    }\n  }\n\n  function onAppKey(e: React.KeyboardEvent, index: number) {\n    const move: Record<string, number> = {\n      ArrowRight: dir === \"rtl\" ? -1 : 1,\n      ArrowLeft: dir === \"rtl\" ? 1 : -1,\n      ArrowDown: cols,\n      ArrowUp: -cols,\n    }\n    if (e.key in move && !e.ctrlKey) {\n      e.preventDefault()\n      focusApp(index + move[e.key]!)\n    } else if (e.key === \"Home\") {\n      e.preventDefault()\n      focusApp(0)\n    } else if (e.key === \"End\") {\n      e.preventDefault()\n      focusApp(shown.length - 1)\n    }\n  }\n\n  return (\n    <AnimatePresence>\n      {open && (\n        <motion.div\n          ref={rootRef}\n          data-slot=\"launchpad\"\n          role=\"dialog\"\n          aria-label={label}\n          onKeyDown={onKeyDown}\n          onWheel={(e) => {\n            wheel.current +=\n              Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY\n            if (Math.abs(wheel.current) > 120) {\n              goPage(current + Math.sign(wheel.current))\n              wheel.current = 0\n            }\n          }}\n          initial={\n            reduce\n              ? { opacity: 0 }\n              : { opacity: 0, scale: 1.12, filter: \"blur(10px)\" }\n          }\n          animate={\n            reduce\n              ? { opacity: 1 }\n              : { opacity: 1, scale: 1, filter: \"blur(0px)\" }\n          }\n          exit={\n            reduce\n              ? { opacity: 0 }\n              : { opacity: 0, scale: 1.12, filter: \"blur(10px)\" }\n          }\n          transition={{\n            duration: reduce ? 0.12 : 0.32,\n            ease: [0.22, 1, 0.36, 1],\n          }}\n          className={cn(\n            \"absolute inset-0 z-40 flex flex-col items-center overflow-hidden bg-black/45 text-white backdrop-blur-3xl backdrop-saturate-150\",\n            className\n          )}\n          {...(props as object)}\n          onClick={(e) => {\n            const target = e.target as HTMLElement\n            if (\n              target === e.currentTarget ||\n              target.hasAttribute(\"data-launchpad-backdrop\")\n            )\n              onClose?.()\n          }}\n        >\n          <div className=\"relative mt-5 w-56 max-w-[70%] shrink-0\">\n            <Search\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute start-2.5 top-1/2 size-3.5 -translate-y-1/2 text-white/70\"\n            />\n            <input\n              ref={searchRef}\n              type=\"search\"\n              value={query}\n              aria-label=\"Search apps\"\n              placeholder=\"Search\"\n              onChange={(e) => {\n                setQuery(e.target.value)\n                setPage(0)\n                setFocus(0)\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\" && shown[0]) onLaunch?.(shown[0])\n                else if (e.key === \"ArrowDown\" && shown.length) {\n                  e.preventDefault()\n                  focusApp(0)\n                }\n              }}\n              className=\"h-7 w-full rounded-lg border border-white/20 bg-white/15 ps-8 pe-3 text-center text-[13px] text-white outline-none placeholder:text-white/70 focus-visible:bg-white/25 focus-visible:ring-[3px] focus-visible:ring-white/40 [&::-webkit-search-cancel-button]:hidden\"\n            />\n          </div>\n\n          <div\n            data-launchpad-backdrop=\"\"\n            className=\"relative min-h-0 w-full flex-1 overflow-hidden\"\n          >\n            <motion.div\n              className=\"flex h-full\"\n              style={{ width: `${pages * 100}%` }}\n              animate={{ x: `${(-current * 100) / pages}%` }}\n              transition={\n                reduce\n                  ? { duration: 0 }\n                  : { type: \"spring\", stiffness: 260, damping: 30 }\n              }\n            >\n              {Array.from({ length: pages }, (_, p) => (\n                <div\n                  key={p}\n                  className=\"flex h-full items-center justify-center\"\n                  style={{ width: `${100 / pages}%` }}\n                  aria-hidden={p !== current ? true : undefined}\n                  {...(p !== current ? { inert: true } : {})}\n                >\n                  <ul\n                    className=\"grid\"\n                    style={{\n                      gridTemplateColumns: `repeat(${cols}, ${cellWidth}px)`,\n                      gridAutoRows: `${cellHeight}px`,\n                    }}\n                    aria-label={`Page ${p + 1} of ${pages}`}\n                  >\n                    {shown\n                      .slice(p * perPage, (p + 1) * perPage)\n                      .map((app, i) => {\n                        const index = p * perPage + i\n                        return (\n                          <li\n                            key={app.id}\n                            className=\"flex items-start justify-center\"\n                          >\n                            <button\n                              type=\"button\"\n                              data-app-index={index}\n                              tabIndex={focus === index ? 0 : -1}\n                              onFocus={() => setFocus(index)}\n                              onKeyDown={(e) => onAppKey(e, index)}\n                              onClick={() => onLaunch?.(app)}\n                              className=\"group/app flex w-[104px] flex-col items-center gap-2 rounded-xl p-1.5 outline-none focus-visible:bg-white/15 focus-visible:ring-[3px] focus-visible:ring-white/50\"\n                            >\n                              <span\n                                aria-hidden=\"true\"\n                                className=\"transition-transform duration-150 group-hover/app:scale-105 group-active/app:scale-95 motion-reduce:transition-none\"\n                              >\n                                {app.icon}\n                              </span>\n                              <span className=\"line-clamp-1 text-[12px] leading-4 font-medium [text-shadow:0_1px_3px_rgb(0_0_0/0.7)]\">\n                                {app.name}\n                              </span>\n                            </button>\n                          </li>\n                        )\n                      })}\n                  </ul>\n                </div>\n              ))}\n            </motion.div>\n            {shown.length === 0 && (\n              <p className=\"absolute inset-0 flex items-center justify-center text-sm text-white/80\">{`No apps match “${query}”.`}</p>\n            )}\n          </div>\n\n          <div\n            className=\"flex h-9 shrink-0 items-center gap-2\"\n            role=\"group\"\n            aria-label=\"Pages\"\n          >\n            {pages > 1 &&\n              Array.from({ length: pages }, (_, p) => (\n                <button\n                  key={p}\n                  type=\"button\"\n                  aria-label={`Page ${p + 1}`}\n                  aria-current={p === current ? \"true\" : undefined}\n                  onClick={() => goPage(p)}\n                  className={cn(\n                    \"size-2 rounded-full transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white\",\n                    p === current ? \"bg-white\" : \"bg-white/40 hover:bg-white/70\"\n                  )}\n                />\n              ))}\n          </div>\n          <p className=\"sr-only\" role=\"status\">\n            {shown.length === 1\n              ? `${shown.length} app, page ${current + 1} of ${pages}`\n              : `${shown.length} apps, page ${current + 1} of ${pages}`}\n          </p>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  )\n}\n\nexport { Launchpad, type LaunchpadProps, type LaunchpadApp }\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}