{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "command-menu-04",
  "type": "registry:block",
  "title": "Command Menu with Global Search",
  "description": "A command menu with global search block.",
  "author": "ephraim duncan <https://ephraimduncan.com>",
  "registryDependencies": [
    "button",
    "command"
  ],
  "dependencies": [
    "@tabler/icons-react",
    "cmdk"
  ],
  "files": [
    {
      "path": "content/components/command-menu/command-menu-04.tsx",
      "type": "registry:component",
      "target": "components/command-menu-04.tsx",
      "content": "'use client';\n\nimport {\n  IconFileText,\n  IconFileTypeDocx,\n  IconFileTypePdf,\n  IconFileTypeXls,\n  IconFileZip,\n  IconFolder,\n  IconMessageCircle,\n  IconPhoto,\n  IconX,\n} from '@tabler/icons-react';\nimport { Command as CommandPrimitive } from 'cmdk';\nimport { type ComponentType, useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport {\n  Command,\n  CommandDialog,\n  CommandEmpty,\n  CommandGroup,\n  CommandItem,\n  CommandList,\n} from '@/components/ui/command';\nimport { cn } from '@/lib/utils';\n\nexport type SearchResultKind = 'chat' | 'image' | 'document' | 'project';\n\nexport type SearchResult = {\n  id: string;\n  kind: SearchResultKind;\n  title: string;\n  /** Shown under the title while searching. Chats use a text excerpt; files use their type. */\n  snippet: string;\n  date: string;\n  /** Images only: CSS background for the thumbnail. */\n  thumbnail?: string;\n};\n\ntype Filter = 'all' | SearchResultKind;\n\nconst filters: { value: Filter; label: string }[] = [\n  { value: 'all', label: 'All' },\n  { value: 'chat', label: 'Chats' },\n  { value: 'image', label: 'Images' },\n  { value: 'document', label: 'Documents' },\n  { value: 'project', label: 'Projects' },\n];\n\nconst fileIcons: Record<string, ComponentType<{ className?: string }>> = {\n  pdf: IconFileTypePdf,\n  docx: IconFileTypeDocx,\n  xlsx: IconFileTypeXls,\n  zip: IconFileZip,\n};\n\nconst kindIcons: Record<\n  SearchResultKind,\n  ComponentType<{ className?: string }>\n> = {\n  chat: IconMessageCircle,\n  image: IconPhoto,\n  document: IconFileText,\n  project: IconFolder,\n};\n\nconst lastOpenedChats: SearchResult[] = [\n  {\n    id: 'c1',\n    kind: 'chat',\n    title: 'Q3 launch checklist',\n    snippet: 'Pin the launch date once legal signs off on the new terms.',\n    date: 'Today',\n  },\n  {\n    id: 'c2',\n    kind: 'chat',\n    title: 'Draft onboarding email sequence',\n    snippet:\n      'Three emails over five days, starting the morning after launch, each with one clear action.',\n    date: 'Today',\n  },\n];\n\nconst recentChats: SearchResult[] = [\n  {\n    id: 'c3',\n    kind: 'chat',\n    title: 'Compare pricing page layouts',\n    snippet:\n      'Three-column pricing works when the middle tier is the one most people should pick. Ship it with the launch.',\n    date: 'Yesterday',\n  },\n  {\n    id: 'c4',\n    kind: 'chat',\n    title: 'Summarize customer interviews',\n    snippet:\n      'Eight of twelve customers mentioned invoice export first. Nobody brought up the dashboard redesign.',\n    date: 'Yesterday',\n  },\n  {\n    id: 'c5',\n    kind: 'chat',\n    title: 'Fix flaky checkout test',\n    snippet:\n      'The test races the payment webhook. Wait for the order status instead of a fixed delay.',\n    date: 'Sep 4',\n  },\n  {\n    id: 'c6',\n    kind: 'chat',\n    title: 'Write release notes for v2.4',\n    snippet:\n      'Lead with the two launch features people asked for most, then list fixes in one short block.',\n    date: 'Sep 2',\n  },\n  {\n    id: 'c7',\n    kind: 'chat',\n    title: 'Plan database index cleanup',\n    snippet:\n      'Drop the four unused indexes on orders first; they cost the most on writes.',\n    date: 'Aug 30',\n  },\n  {\n    id: 'c8',\n    kind: 'chat',\n    title: 'Brainstorm team offsite agenda',\n    snippet:\n      'Half a day on the roadmap, half a day on the launch retro, evening free.',\n    date: 'Aug 28',\n  },\n  {\n    id: 'c9',\n    kind: 'chat',\n    title: 'Review API rate limit options',\n    snippet:\n      'Token bucket per API key, 600 requests a minute. Raise the ceiling for launch week.',\n    date: 'Aug 26',\n  },\n];\n\nconst images: SearchResult[] = [\n  {\n    id: 'i1',\n    kind: 'image',\n    title: 'launch-hero-banner.png',\n    snippet: 'Image',\n    date: 'Sep 3',\n    thumbnail: 'linear-gradient(135deg, #f97316, #db2777)',\n  },\n  {\n    id: 'i2',\n    kind: 'image',\n    title: 'launch-pricing-table.png',\n    snippet: 'Image',\n    date: 'Aug 20',\n    thumbnail: 'linear-gradient(135deg, #0ea5e9, #6366f1)',\n  },\n  {\n    id: 'i3',\n    kind: 'image',\n    title: 'launch-onboarding-flow.png',\n    snippet: 'Image',\n    date: 'Aug 27',\n    thumbnail: 'linear-gradient(135deg, #22c55e, #0d9488)',\n  },\n  {\n    id: 'i4',\n    kind: 'image',\n    title: 'dashboard-dark-mode.png',\n    snippet: 'Image',\n    date: 'Aug 15',\n    thumbnail: 'linear-gradient(135deg, #1e293b, #475569)',\n  },\n  {\n    id: 'i5',\n    kind: 'image',\n    title: 'launch-social-card.jpg',\n    snippet: 'Image',\n    date: 'Aug 12',\n    thumbnail: 'linear-gradient(135deg, #a855f7, #f43f5e)',\n  },\n  {\n    id: 'i6',\n    kind: 'image',\n    title: 'checkout-error-states.png',\n    snippet: 'Image',\n    date: 'Jul 30',\n    thumbnail: 'linear-gradient(135deg, #eab308, #f97316)',\n  },\n];\n\nconst documents: SearchResult[] = [\n  {\n    id: 'd1',\n    kind: 'document',\n    title: 'Q3_launch_plan.pdf',\n    snippet: 'PDF',\n    date: 'Sep 1',\n  },\n  {\n    id: 'd2',\n    kind: 'document',\n    title: 'launch-pricing-proposal.docx',\n    snippet: 'Document',\n    date: 'Aug 22',\n  },\n  {\n    id: 'd3',\n    kind: 'document',\n    title: 'pre-launch-interview-notes.md',\n    snippet: 'Document',\n    date: 'Aug 19',\n  },\n  {\n    id: 'd4',\n    kind: 'document',\n    title: 'launch-assets-final.zip',\n    snippet: 'File',\n    date: 'Aug 11',\n  },\n  {\n    id: 'd5',\n    kind: 'document',\n    title: 'pricing-experiments.xlsx',\n    snippet: 'Spreadsheet',\n    date: 'Aug 14',\n  },\n];\n\nconst projects: SearchResult[] = [\n  {\n    id: 'p1',\n    kind: 'project',\n    title: 'Q3 product launch',\n    snippet: 'Project',\n    date: 'Sep 5',\n  },\n  {\n    id: 'p2',\n    kind: 'project',\n    title: 'Launch pricing and packaging',\n    snippet: 'Project',\n    date: 'Aug 30',\n  },\n  {\n    id: 'p3',\n    kind: 'project',\n    title: 'Post-launch onboarding redesign',\n    snippet: 'Project',\n    date: 'Aug 21',\n  },\n  {\n    id: 'p4',\n    kind: 'project',\n    title: 'Checkout reliability',\n    snippet: 'Project',\n    date: 'Jul 28',\n  },\n];\n\nconst searchResults: SearchResult[] = [\n  ...lastOpenedChats,\n  ...recentChats,\n  ...images,\n  ...documents,\n  ...projects,\n];\n\nexport function CommandMenu04() {\n  const [open, setOpen] = useState(true);\n\n  return (\n    <>\n      <Button onClick={() => setOpen(true)} variant=\"outline\">\n        Search\n      </Button>\n\n      <GlobalSearch\n        lastOpened={lastOpenedChats}\n        onOpenChange={setOpen}\n        onSelect={() => setOpen(false)}\n        open={open}\n        recent={recentChats}\n        results={searchResults}\n      />\n    </>\n  );\n}\n\nexport function GlobalSearch({\n  open,\n  onOpenChange,\n  onSelect,\n  lastOpened,\n  recent,\n  results,\n}: {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  onSelect: (result: SearchResult) => void;\n  lastOpened: SearchResult[];\n  recent: SearchResult[];\n  results: SearchResult[];\n}) {\n  const [query, setQuery] = useState('');\n  const [filter, setFilter] = useState<Filter>('all');\n  const needle = query.trim().toLowerCase();\n\n  const matches = results.filter(\n    (result) =>\n      (filter === 'all' || result.kind === filter) &&\n      `${result.title} ${result.snippet}`.toLowerCase().includes(needle)\n  );\n\n  const reset = () => {\n    setQuery('');\n    setFilter('all');\n  };\n\n  return (\n    <CommandDialog\n      className=\"-translate-y-1/2 top-1/2 rounded-2xl! sm:max-w-[720px]\"\n      description=\"Search chats, images, documents, and projects\"\n      onOpenChange={(next) => {\n        if (!next) {\n          reset();\n        }\n        onOpenChange(next);\n      }}\n      open={open}\n      title=\"Global search\"\n    >\n      <Command shouldFilter={false}>\n        <div className=\"flex h-16 shrink-0 items-center py-5 pr-4 pl-6\">\n          <CommandPrimitive.Input\n            className=\"min-w-0 flex-1 bg-transparent text-base tracking-[-0.02em] outline-hidden placeholder:text-muted-foreground\"\n            onValueChange={setQuery}\n            placeholder=\"Search...\"\n            value={query}\n          />\n          <div className=\"flex shrink-0 items-center gap-1\">\n            {query && (\n              <>\n                <button\n                  className=\"px-2 text-muted-foreground text-sm hover:text-foreground\"\n                  onClick={reset}\n                  type=\"button\"\n                >\n                  Clear\n                </button>\n                <span aria-hidden className=\"mx-1 h-5 w-px bg-border\" />\n              </>\n            )}\n            <button\n              aria-label=\"Close search\"\n              className=\"flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground\"\n              onClick={() => onOpenChange(false)}\n              type=\"button\"\n            >\n              <IconX className=\"size-5\" />\n            </button>\n          </div>\n        </div>\n\n        <CommandList className=\"h-[396px] max-h-[396px] pb-3\">\n          {needle ? (\n            <>\n              <div\n                aria-label=\"Result type filters\"\n                className=\"sticky top-0 z-10 flex gap-1 bg-popover px-6 pb-3\"\n                role=\"tablist\"\n              >\n                {filters.map((option) => {\n                  const selected = option.value === filter;\n                  return (\n                    <button\n                      aria-selected={selected}\n                      className={cn(\n                        'rounded-full px-3.5 py-1.5 text-sm transition-colors',\n                        selected\n                          ? 'bg-muted text-foreground'\n                          : 'text-muted-foreground hover:text-foreground'\n                      )}\n                      key={option.value}\n                      onClick={() => setFilter(option.value)}\n                      role=\"tab\"\n                      type=\"button\"\n                    >\n                      {option.label}\n                    </button>\n                  );\n                })}\n              </div>\n              <CommandEmpty>No results for “{query}”.</CommandEmpty>\n              {filter === 'image' ? (\n                <div className=\"grid grid-cols-4 gap-3 px-6\">\n                  {matches.map((result) => (\n                    <CommandItem\n                      className=\"block aspect-square rounded-xl! bg-transparent! p-0 [&_svg]:hidden\"\n                      key={result.id}\n                      onSelect={() => onSelect(result)}\n                      value={result.id}\n                    >\n                      <span\n                        aria-label={result.title}\n                        className=\"block size-full rounded-xl ring-1 ring-foreground/10 ring-offset-2 ring-offset-popover group-data-selected/command-item:ring-foreground/40\"\n                        role=\"img\"\n                        style={{ background: result.thumbnail }}\n                      />\n                    </CommandItem>\n                  ))}\n                </div>\n              ) : (\n                matches.map((result) => (\n                  <ResultRow\n                    key={result.id}\n                    needle={needle}\n                    onSelect={onSelect}\n                    result={result}\n                  />\n                ))\n              )}\n            </>\n          ) : (\n            <>\n              <CommandGroup\n                className=\"p-0 **:[[cmdk-group-heading]]:px-6 **:[[cmdk-group-heading]]:py-2 **:[[cmdk-group-heading]]:font-normal! **:[[cmdk-group-heading]]:text-sm!\"\n                heading=\"Last opened\"\n              >\n                {lastOpened.map((result) => (\n                  <ResultRow\n                    key={result.id}\n                    onSelect={onSelect}\n                    result={result}\n                  />\n                ))}\n              </CommandGroup>\n              <CommandGroup\n                className=\"mt-5 p-0 **:[[cmdk-group-heading]]:px-6 **:[[cmdk-group-heading]]:py-2 **:[[cmdk-group-heading]]:font-normal! **:[[cmdk-group-heading]]:text-sm!\"\n                heading=\"Recent chats\"\n              >\n                {recent.map((result) => (\n                  <ResultRow\n                    key={result.id}\n                    onSelect={onSelect}\n                    result={result}\n                  />\n                ))}\n              </CommandGroup>\n            </>\n          )}\n        </CommandList>\n      </Command>\n    </CommandDialog>\n  );\n}\n\nfunction ResultRow({\n  result,\n  needle,\n  onSelect,\n}: {\n  result: SearchResult;\n  needle?: string;\n  onSelect: (result: SearchResult) => void;\n}) {\n  const extension = result.title.split('.').pop() ?? '';\n  const Icon =\n    result.kind === 'document'\n      ? (fileIcons[extension] ?? IconFileText)\n      : kindIcons[result.kind];\n\n  return (\n    <CommandItem\n      className=\"mx-3 min-h-12 gap-3 rounded-xl! px-3 py-2 text-sm\"\n      onSelect={() => onSelect(result)}\n      value={result.id}\n    >\n      <span className=\"flex size-8 shrink-0 items-center justify-center\">\n        <Icon\n          className={cn('size-5!', extension === 'pdf' && 'text-red-500')}\n        />\n      </span>\n      <span className=\"min-w-0 flex-1\">\n        <span className=\"block truncate leading-5\">\n          <Highlight needle={needle} text={result.title} />\n        </span>\n        {needle && (\n          <span className=\"block truncate pt-0.5 text-muted-foreground leading-5\">\n            <Highlight needle={needle} text={result.snippet} />\n          </span>\n        )}\n      </span>\n      {needle && (\n        <span className=\"shrink-0 pl-4 text-muted-foreground text-sm\">\n          {result.date}\n        </span>\n      )}\n    </CommandItem>\n  );\n}\n\nfunction Highlight({ text, needle }: { text: string; needle?: string }) {\n  const start = needle ? text.toLowerCase().indexOf(needle) : -1;\n  if (!needle || start === -1) {\n    return text;\n  }\n  const end = start + needle.length;\n  return (\n    <>\n      {text.slice(0, start)}\n      <mark className=\"bg-transparent font-semibold text-foreground\">\n        {text.slice(start, end)}\n      </mark>\n      {text.slice(end)}\n    </>\n  );\n}\n"
    }
  ],
  "categories": [
    "command-menu"
  ]
}