{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "desktop-2",
  "title": "Desktop 2: menu bar and tray panel",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "The panel of a menu bar app on macOS or a tray flyout on Windows: a clipboard history with search, pins, a pause switch, keyboard selection and Preferences and Quit, with loading, empty and error states.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/kbd.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json",
    "https://ui.ballmac.com/r/switch.json",
    "https://ui.ballmac.com/r/feel-presets.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/desktop-2/desktop-2.tsx",
      "content": "// Ballmac UI: Desktop 2. https://ui.ballmac.com/blocks/desktop-2\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ClipboardList, Code2, Link2, Pin, Power, Search, Settings, Trash2, Type, Wifi } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Kbd } from \"@/components/ballmac/kbd\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { Switch } from \"@/components/ballmac/switch\"\nimport { useFeel, type FeelId } from \"@/lib/ballmac/feel\"\nimport { cn } from \"@/lib/utils\"\n\ntype Desktop2Item = {\n  id: string\n  /** What was copied, as text. */\n  text: string\n  kind?: \"text\" | \"link\" | \"code\" | \"color\"\n  /** The app it was copied from. */\n  source?: string\n  /** When, already written for people (\"2 min ago\"). */\n  time?: string\n  pinned?: boolean\n}\n\ntype Desktop2Props = Omit<React.ComponentProps<\"div\">, \"children\"> &\n  StateProps & {\n    /** Which platform the panel handles like. Defaults to the page's feel. */\n    feel?: FeelId\n    /** Draw the menu bar (macOS) or taskbar (Windows) the panel opens from. Turn off inside the real tray window. */\n    frame?: boolean\n    /** The app name, used in the header and the Quit item. */\n    appName?: string\n    /** The clipboard history, newest first. Pinned items are listed first. */\n    items?: Desktop2Item[]\n    /** Whether recording is paused (controlled). */\n    paused?: boolean\n    /** Called when the pause switch changes. */\n    onPausedChange?: (paused: boolean) => void\n    /** Called when an item is chosen with a click or Enter. Defaults to writing the text to the clipboard. */\n    onCopy?: (item: Desktop2Item) => void | Promise<void>\n    /** Called when an item's pin is toggled. Without it the panel keeps pins itself. */\n    onTogglePin?: (item: Desktop2Item) => void\n    /** Called by \"Clear history\". */\n    onClear?: () => void\n    /** Called by the Preferences item. */\n    onOpenPreferences?: () => void\n    /** Called by the Quit item. */\n    onQuit?: () => void\n    /** A fixed clock for the menu bar, as text. */\n    clock?: string\n  }\n\nconst sample: Desktop2Item[] = [\n  { id: \"1\", text: \"https://acme.com/changelog/2-4\", kind: \"link\", source: \"Safari\", time: \"Just now\" },\n  { id: \"2\", text: \"Ship the release notes before the 4 pm call.\", kind: \"text\", source: \"Notes\", time: \"2 min ago\", pinned: true },\n  { id: \"3\", text: \"pnpm tauri build --target universal-apple-darwin\", kind: \"code\", source: \"Terminal\", time: \"12 min ago\" },\n  { id: \"4\", text: \"oklch(0.62 0.19 259)\", kind: \"color\", source: \"Figma\", time: \"25 min ago\" },\n  { id: \"5\", text: \"Invoice 2026-118 is attached; payment is due on the 30th.\", kind: \"text\", source: \"Mail\", time: \"1 hr ago\" },\n  { id: \"6\", text: \"ssh deploy@build-01.internal\", kind: \"code\", source: \"Terminal\", time: \"Yesterday\" },\n]\n\nconst kindIcon = { text: Type, link: Link2, code: Code2 } as const\n\nfunction KindIcon({ item, className }: { item: Desktop2Item; className?: string }) {\n  const kind = item.kind ?? \"text\"\n  // the swatch shows the copied value itself, so it is data rather than a design colour\n  if (kind === \"color\")\n    return <span aria-hidden=\"true\" className=\"size-4 shrink-0 rounded-[5px] border border-black/10\" style={{ background: item.text }} />\n  const Icon = kindIcon[kind]\n  return <Icon aria-hidden=\"true\" className={cn(\"text-muted-foreground size-4 shrink-0\", className)} />\n}\n\nfunction PanelSkeleton() {\n  return (\n    <div className=\"grid gap-1 p-2\" aria-hidden=\"true\">\n      {[0, 1, 2, 3, 4].map((i) => (\n        <div key={i} className=\"flex items-center gap-3 px-2 py-2\">\n          <Skeleton className=\"size-4 rounded-[5px]\" />\n          <div className=\"grid flex-1 gap-1.5\">\n            <Skeleton className=\"h-3.5\" style={{ width: `${88 - i * 9}%` }} />\n            <Skeleton className=\"h-2.5 w-1/3\" />\n          </div>\n        </div>\n      ))}\n    </div>\n  )\n}\n\n/** The panel itself: header with the pause switch, search, the history as a listbox, and the footer menu. */\nfunction Panel({\n  feel,\n  appName,\n  items: itemsProp,\n  paused: pausedProp,\n  onPausedChange,\n  onCopy,\n  onTogglePin,\n  onClear,\n  onOpenPreferences,\n  onQuit,\n  state,\n  onRetry,\n  emptyState,\n  errorState,\n}: Required<Pick<Desktop2Props, \"feel\" | \"appName\">> & Omit<Desktop2Props, \"feel\" | \"appName\" | \"frame\" | \"clock\">) {\n  const mac = feel === \"macos\" || feel === \"ios\"\n  const id = React.useId()\n  const [localItems, setLocalItems] = React.useState(itemsProp ?? sample)\n  React.useEffect(() => setLocalItems(itemsProp ?? sample), [itemsProp])\n  const [localPaused, setLocalPaused] = React.useState(false)\n  const paused = pausedProp ?? localPaused\n  const [query, setQuery] = React.useState(\"\")\n  const [active, setActive] = React.useState(0)\n  const [copied, setCopied] = React.useState<string | null>(null)\n\n  const q = query.trim().toLowerCase()\n  const visible = localItems\n    .filter((i) => !q || i.text.toLowerCase().includes(q) || i.source?.toLowerCase().includes(q))\n    .sort((a, b) => Number(!!b.pinned) - Number(!!a.pinned))\n  const current = visible[Math.min(active, visible.length - 1)]\n  React.useEffect(() => setActive(0), [q])\n  // keep the selected item in view while the arrow keys move through a long history; only the list scrolls, never the page\n  const listRef = React.useRef<HTMLUListElement>(null)\n  React.useEffect(() => {\n    const list = listRef.current\n    const el = current && list?.querySelector<HTMLElement>(`[id=\"${id}-${current.id}\"]`)\n    if (!list || !el) return\n    if (el.offsetTop < list.scrollTop) list.scrollTop = el.offsetTop - 6\n    else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight) list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight + 6\n  }, [current, id])\n  React.useEffect(() => {\n    if (!copied) return\n    const t = setTimeout(() => setCopied(null), 1600)\n    return () => clearTimeout(t)\n  }, [copied])\n\n  async function copy(item: Desktop2Item) {\n    try {\n      if (onCopy) await onCopy(item)\n      else await navigator.clipboard?.writeText(item.text)\n      setCopied(item.id)\n    } catch {\n      setCopied(null)\n    }\n  }\n  function togglePin(item: Desktop2Item) {\n    if (onTogglePin) onTogglePin(item)\n    else setLocalItems((list) => list.map((i) => (i.id === item.id ? { ...i, pinned: !i.pinned } : i)))\n  }\n\n  const ready = state === \"ready\"\n  const modKey = mac ? \"⌘\" : \"Ctrl+\"\n  const footerItem =\n    \"flex h-[var(--bm-menu-item-h,1.75rem)] w-full items-center gap-2 rounded-[var(--bm-menu-item-radius,0.375rem)] px-2 text-start text-[13px] outline-none hover:bg-foreground/[0.06] focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:text-muted-foreground\"\n\n  return (\n    <div\n      data-slot=\"desktop-2-panel\"\n      className={cn(\n        \"bg-popover/[var(--bm-material-alpha,100%)] text-popover-foreground flex w-[min(22rem,calc(100vw-2rem))] flex-col overflow-hidden border backdrop-blur-[var(--bm-material-blur,0px)]\",\n        \"shadow-[var(--bm-shadow-popover,0_12px_36px_-10px_rgb(0_0_0/0.25))]\",\n        mac ? \"rounded-xl\" : \"rounded-lg\"\n      )}\n    >\n      <div className=\"flex items-center gap-2 px-3 pt-3 pb-2\">\n        <span aria-hidden=\"true\" className=\"bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md [&_svg]:size-3.5\">\n          <ClipboardList />\n        </span>\n        <p className=\"min-w-0 flex-1 truncate text-sm font-semibold\">{appName}</p>\n        <label htmlFor={`${id}-pause`} className=\"text-muted-foreground text-xs\">\n          {paused ? \"Paused\" : \"Recording\"}\n        </label>\n        <Switch\n          id={`${id}-pause`}\n          size=\"sm\"\n          checked={!paused}\n          onCheckedChange={(on) => {\n            setLocalPaused(!on)\n            onPausedChange?.(!on)\n          }}\n          aria-label=\"Record copied items\"\n        />\n      </div>\n\n      <div className=\"px-3 pb-2\">\n        <div className=\"bg-foreground/[0.05] focus-within:ring-ring/50 flex h-8 items-center gap-2 rounded-md px-2 focus-within:ring-[3px]\">\n          <Search aria-hidden=\"true\" className=\"text-muted-foreground size-3.5 shrink-0\" />\n          <input\n            role=\"combobox\"\n            aria-label=\"Search clipboard history\"\n            aria-expanded={ready && visible.length > 0}\n            aria-controls={`${id}-list`}\n            aria-activedescendant={ready && current ? `${id}-${current.id}` : undefined}\n            aria-autocomplete=\"list\"\n            aria-describedby={`${id}-keys`}\n            disabled={!ready}\n            value={query}\n            onChange={(e) => setQuery(e.target.value)}\n            onKeyDown={(e) => {\n              if (!visible.length) return\n              if (e.key === \"ArrowDown\") {\n                e.preventDefault()\n                setActive((a) => (a + 1) % visible.length)\n              } else if (e.key === \"ArrowUp\") {\n                e.preventDefault()\n                setActive((a) => (a - 1 + visible.length) % visible.length)\n              } else if (e.key === \"Enter\" && current) {\n                e.preventDefault()\n                copy(current)\n              } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"p\" && current) {\n                e.preventDefault()\n                togglePin(current)\n              }\n            }}\n            placeholder=\"Search\"\n            className=\"placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent text-[13px] outline-none disabled:opacity-50\"\n          />\n        </div>\n      </div>\n\n      <StateView\n        state={state ?? \"ready\"}\n        onRetry={onRetry}\n        bare\n        label=\"Loading clipboard history\"\n        loading={<PanelSkeleton />}\n        empty={{ title: emptyState?.title ?? \"Nothing copied yet\", description: emptyState?.description ?? `Copy text, a link or a color in any app and ${appName} keeps it here.`, icon: emptyState?.icon ?? <ClipboardList />, action: emptyState?.action }}\n        error={{ title: errorState?.title ?? \"History is unavailable\", description: errorState?.description ?? \"The history file could not be read.\", ...errorState }}\n        className=\"min-h-[15rem] border-t\"\n      >\n        {visible.length ? (\n          <ul ref={listRef} id={`${id}-list`} role=\"listbox\" aria-label=\"Clipboard history\" className=\"relative max-h-[21rem] overflow-y-auto p-1.5\">\n            {visible.map((item, i) => {\n              const selected = current?.id === item.id\n              return (\n                <li\n                  key={item.id}\n                  id={`${id}-${item.id}`}\n                  role=\"option\"\n                  aria-selected={selected}\n                  onMouseMove={() => setActive(i)}\n                  onClick={() => copy(item)}\n                  className={cn(\n                    \"group/item flex cursor-default items-center gap-3 rounded-[var(--bm-menu-item-radius,0.375rem)] px-2 py-1.5\",\n                    selected && (mac ? \"bg-primary text-primary-foreground\" : \"bg-foreground/[0.07]\")\n                  )}\n                >\n                  <KindIcon item={item} className={cn(selected && mac && \"text-primary-foreground/80\")} />\n                  <div className=\"min-w-0 flex-1\">\n                    <p dir={item.kind === \"code\" || item.kind === \"link\" ? \"ltr\" : undefined} className={cn(\"truncate text-[13px]\", item.kind === \"code\" && \"font-mono text-xs\")}>\n                      {item.text}\n                    </p>\n                    <p className={cn(\"truncate text-[11px]\", selected && mac ? \"text-primary-foreground/75\" : \"text-muted-foreground\")}>\n                      {[item.source, item.time].filter(Boolean).join(\" · \")}\n                    </p>\n                  </div>\n                  {copied === item.id ? (\n                    <Check aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n                  ) : (\n                    // a pointer shortcut only: an option cannot hold a button, so keyboard users pin with ⌘P or Ctrl+P\n                    <span\n                      aria-hidden=\"true\"\n                      title={item.pinned ? \"Unpin\" : \"Pin\"}\n                      onClick={(e) => {\n                        e.stopPropagation()\n                        togglePin(item)\n                      }}\n                      className={cn(\"rounded p-0.5 opacity-0 group-hover/item:opacity-100 [&_svg]:size-3.5\", (item.pinned || selected) && \"opacity-100\")}\n                    >\n                      <Pin className={cn(item.pinned && \"fill-current\")} />\n                    </span>\n                  )}\n                </li>\n              )\n            })}\n          </ul>\n        ) : (\n          <p className=\"text-muted-foreground px-4 py-10 text-center text-sm\">No matches for “{query}”.</p>\n        )}\n      </StateView>\n      <p id={`${id}-keys`} className=\"sr-only\">\n        {`Up and down arrows choose an item, Enter copies it, ${mac ? \"Command\" : \"Control\"} P pins it.`}\n      </p>\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {copied ? \"Copied\" : \"\"}\n      </p>\n\n      <div className=\"border-t p-1.5\">\n        {mac ? (\n          <div className=\"grid\">\n            <button type=\"button\" className={footerItem} onClick={onClear} disabled={!ready}>\n              <Trash2 aria-hidden=\"true\" />\n              <span className=\"flex-1\">Clear history</span>\n            </button>\n            <button type=\"button\" className={footerItem} onClick={onOpenPreferences}>\n              <Settings aria-hidden=\"true\" />\n              <span className=\"flex-1\">Preferences…</span>\n              <Kbd dir=\"ltr\" className=\"bg-transparent\">{modKey},</Kbd>\n            </button>\n            <button type=\"button\" className={footerItem} onClick={onQuit}>\n              <Power aria-hidden=\"true\" />\n              <span className=\"flex-1\">Quit {appName}</span>\n              <Kbd dir=\"ltr\" className=\"bg-transparent\">{modKey}Q</Kbd>\n            </button>\n          </div>\n        ) : (\n          <div className=\"flex items-center gap-1\">\n            <Button variant=\"ghost\" size=\"sm\" onClick={onClear} disabled={!ready}>\n              <Trash2 aria-hidden=\"true\" />\n              Clear\n            </Button>\n            <span className=\"flex-1\" />\n            <Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Settings\" onClick={onOpenPreferences}>\n              <Settings aria-hidden=\"true\" />\n            </Button>\n            <Button variant=\"ghost\" size=\"icon-sm\" aria-label={`Quit ${appName}`} onClick={onQuit}>\n              <Power aria-hidden=\"true\" />\n            </Button>\n          </div>\n        )}\n      </div>\n    </div>\n  )\n}\n\n/**\n * A menu bar app (macOS) or tray flyout (Windows): a clipboard history with search, pins and a pause switch. With frame on it\n * is drawn opening from a menu bar slice on macOS and above a taskbar on Windows, so it reads as a status-item app at a glance.\n */\nfunction Desktop2({\n  feel: feelProp,\n  frame = true,\n  appName = \"Clips\",\n  clock = \"Fri 9:41\",\n  state = \"ready\",\n  className,\n  ...props\n}: Desktop2Props) {\n  const page = useFeel().feel\n  const feel = feelProp ?? page\n  const win = feel === \"windows\"\n  const {\n    items,\n    paused,\n    onPausedChange,\n    onCopy,\n    onTogglePin,\n    onClear,\n    onOpenPreferences,\n    onQuit,\n    onRetry,\n    emptyState,\n    errorState,\n    ...rest\n  } = props\n  const panel = (\n    <Panel\n      feel={feel}\n      appName={appName}\n      state={state}\n      {...{ items, paused, onPausedChange, onCopy, onTogglePin, onClear, onOpenPreferences, onQuit, onRetry, emptyState, errorState }}\n    />\n  )\n  if (!frame)\n    return (\n      <div data-slot=\"desktop-2\" data-state={state} data-feel={feelProp} className={className} {...frameProps(rest)}>\n        {panel}\n      </div>\n    )\n\n  const statusIcon = (\n    <span aria-hidden=\"true\" className={cn(\"flex items-center justify-center rounded [&_svg]:size-3.5\", win ? \"bg-foreground/10 size-7\" : \"bg-foreground/15 h-5 w-7\")}>\n      <ClipboardList />\n    </span>\n  )\n  return (\n    <div\n      data-slot=\"desktop-2\"\n      data-state={state}\n      data-feel={feel}\n      className={cn(\"bg-muted/60 relative flex h-[36rem] w-full max-w-2xl flex-col overflow-hidden rounded-xl border\", win ? \"justify-end\" : \"justify-start\", className)}\n      {...frameProps(rest)}\n    >\n      {win ? (\n        <>\n          <div className=\"flex justify-end px-3 pb-3\">{panel}</div>\n          <div aria-hidden=\"true\" className=\"bg-background/80 flex h-11 shrink-0 items-center justify-end gap-2 border-t px-3 backdrop-blur\">\n            {statusIcon}\n            <Wifi className=\"text-muted-foreground size-3.5\" />\n            <span dir=\"ltr\" className=\"text-muted-foreground text-[11px] tabular-nums\">{clock}</span>\n          </div>\n        </>\n      ) : (\n        <>\n          <div aria-hidden=\"true\" className=\"bg-background/70 flex h-6 shrink-0 items-center justify-end gap-3 px-3 text-[12px] backdrop-blur\">\n            {statusIcon}\n            <Wifi className=\"size-3.5\" />\n            <span dir=\"ltr\" className=\"tabular-nums\">{clock}</span>\n          </div>\n          <div className=\"flex justify-end px-3 pt-1.5\">{panel}</div>\n        </>\n      )}\n    </div>\n  )\n}\n\nexport { Desktop2, type Desktop2Props, type Desktop2Item }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/desktop-2/desktop-2.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "desktop",
      "menu bar",
      "tray",
      "status item",
      "popover",
      "clipboard",
      "tauri",
      "electron",
      "macos",
      "windows",
      "feel"
    ],
    "version": "1.0.0",
    "updated": "2026-10-17",
    "tests": true,
    "ai": {
      "summary": "A status-item app panel. <Desktop2 appName items={[{ id, text, kind: 'text' | 'link' | 'code' | 'color', source, time, pinned }]} onCopy onTogglePin onClear onOpenPreferences onQuit />. frame draws a menu bar slice (macOS) or taskbar (Windows) around it for marketing pages; frame={false} renders only the panel for the real tray window. Swap the list for your own content by forking the file: the header, search, listbox and footer are separate parts.",
      "whenToUse": [
        "The popover of a macOS menu bar app or a Windows tray app built with Tauri or Electron",
        "Showing a menu bar utility on its landing page"
      ],
      "whenNotToUse": [
        "Command search across an app (use command or spotlight-search)",
        "A plain dropdown menu (use dropdown-menu)"
      ],
      "composesWith": [
        "desktop-1",
        "app-window",
        "feel-auto"
      ],
      "a11y": [
        {
          "keys": "Up / Down in the search field",
          "action": "Moves through the history (a combobox with a listbox); the item is announced"
        },
        {
          "keys": "Enter",
          "action": "Copies the selected item; \"Copied\" is announced"
        },
        {
          "keys": "⌘P or Ctrl+P",
          "action": "Pins or unpins the selected item"
        }
      ],
      "customization": [
        "feel: 'macos' | 'windows' (defaults to the page's feel); macOS draws menu-style footer rows, Windows icon buttons",
        "paused + onPausedChange control the recording switch",
        "Tauri: open it with a tray icon (tray-icon feature) and a borderless, transparent window, then render frame={false}",
        "state, onRetry, emptyState and errorState as in every Ballmac block with states"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "desktop-2-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton rows the shape of history items, announced as loading; search and Clear are disabled."
      },
      {
        "id": "empty",
        "title": "Nothing copied yet",
        "example": "desktop-2-empty",
        "trigger": "state=\"empty\"",
        "note": "Explains that copied items will appear; emptyState sets the copy and can add an action."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "desktop-2-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry); Preferences and Quit still work."
      }
    ],
    "examples": [
      "desktop-2-demo",
      "desktop-2-windows",
      "desktop-2-loading",
      "desktop-2-empty",
      "desktop-2-error"
    ],
    "url": "https://ui.ballmac.com/components/desktop-2"
  },
  "categories": [
    "blocks",
    "desktop"
  ],
  "type": "registry:block"
}