jengacore.shadcn registry · Tailwind v4 · Radix and Base UI
Components
jengacore components · @jengacore/command-palette

CommandPalette

The ⌘K palette: Records → Actions → Go to → Switch product → Appearance, fed by each product's data.

Install

terminal
npx shadcn@latest add @jengacore/command-palette

Without the namespace configured: npx shadcn@latest add https://ui.jengacore.com/r/<new-york-v4|base-nova>/command-palette.json

npm packages

lucide-react

Source

components/jc/command-palette.tsx
"use client"

import * as React from "react"
import { Contrast, CornerDownLeft, Moon, Sun } from "lucide-react"

import {
  CommandDialog,
  CommandEmpty,
  CommandFooter,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
  CommandShortcut,
} from "@/components/ui/command"
import { Kbd } from "@/components/ui/kbd"
import { PRODUCT_NAME, PRODUCT_TILE, type Product, type Theme } from "@/components/jc/theme"

type Icon = React.ComponentType<{ className?: string }>

export type PaletteRecord = {
  /** Shown in mono: tx_01J9ZK4Q7M, ALT-20931, M-004211. */
  id: string
  icon?: Icon
  /** Main label, if the record has a name (a participant, a member). */
  label?: string
  /** Muted context: "Horn Commercial Bank → Sahan Mobile Money". */
  context?: string
  /** Current status, usually a <StatusBadge />. */
  status?: React.ReactNode
  keywords?: string[]
  onSelect: () => void
}

export type PaletteAction = { label: string; icon?: Icon; shortcut?: string[]; keywords?: string[]; onSelect: () => void }

/** ⌘K / Ctrl+K anywhere, "/" when focus is not in a field. */
export function useCommandShortcut(setOpen: React.Dispatch<React.SetStateAction<boolean>>) {
  React.useEffect(() => {
    const down = (e: KeyboardEvent) => {
      const t = e.target
      const typing = t instanceof HTMLElement && (t.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(t.tagName))
      if ((e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) || (e.key === "/" && !typing)) {
        e.preventDefault()
        setOpen((o) => !o)
      }
    }
    document.addEventListener("keydown", down)
    return () => document.removeEventListener("keydown", down)
  }, [setOpen])
}

function Keys({ keys }: { keys?: string[] }) {
  if (!keys?.length) return null
  return (
    <CommandShortcut>
      {keys.map((k) => (
        <Kbd key={k}>{k}</Kbd>
      ))}
    </CommandShortcut>
  )
}

/**
 * The ⌘K palette every jengacore product shares. Same anatomy everywhere:
 * Records (search hits with IDs and status) → Actions (verbs with shortcuts) → Go to (two-key chords)
 * → Switch product → Appearance (Light / Dark / High contrast). Each product supplies its own data.
 */
export function CommandPalette({
  open,
  onOpenChange,
  product,
  placeholder = "Search records, pages and actions",
  emptyText = "No match. Try an ID, an MSISDN or a name.",
  records = [],
  actions = [],
  pages = [],
  products,
  onSwitchProduct,
  onTheme,
  query,
  onQueryChange,
  shouldFilter,
}: {
  open: boolean
  onOpenChange: (open: boolean) => void
  product?: Product
  placeholder?: string
  emptyText?: string
  records?: PaletteRecord[]
  actions?: PaletteAction[]
  /** Go to: pages with their chords, e.g. ["G", "O"]. */
  pages?: PaletteAction[]
  /** Products this user can switch to. */
  products?: Product[]
  onSwitchProduct?: (product: Product) => void
  onTheme?: (theme: Theme) => void
  /** Controlled search, for products that fetch records from the server. */
  query?: string
  onQueryChange?: (query: string) => void
  /** Set false when `records` are already filtered by the server. */
  shouldFilter?: boolean
}) {
  const run = (fn?: () => void) => () => {
    onOpenChange(false)
    fn?.()
  }
  const groups: React.ReactNode[] = []

  if (records.length)
    groups.push(
      <CommandGroup key="records" heading="Records">
        {records.map((r) => {
          const RIcon = r.icon
          return (
            <CommandItem key={r.id} value={[r.id, r.label, r.context, ...(r.keywords ?? [])].filter(Boolean).join(" ")} onSelect={run(r.onSelect)}>
              {RIcon ? <RIcon /> : null}
              {r.label ? <span className="truncate">{r.label}</span> : null}
              <span className={r.label ? "font-mono text-xs text-muted-foreground" : "font-mono text-[13px]"}>{r.id}</span>
              {r.context ? <span className="truncate text-muted-foreground">{r.context}</span> : null}
              {r.status ? <span className="ml-auto">{r.status}</span> : null}
            </CommandItem>
          )
        })}
      </CommandGroup>
    )

  const actionGroup = (key: string, heading: string, items: PaletteAction[]) =>
    items.length ? (
      <CommandGroup key={key} heading={heading}>
        {items.map((a) => {
          const AIcon = a.icon
          return (
            <CommandItem key={a.label} value={[heading, a.label, ...(a.keywords ?? [])].join(" ")} onSelect={run(a.onSelect)}>
              {AIcon ? <AIcon /> : null}
              {a.label}
              <Keys keys={a.shortcut} />
            </CommandItem>
          )
        })}
      </CommandGroup>
    ) : null

  groups.push(actionGroup("actions", "Actions", actions), actionGroup("pages", "Go to", pages))

  if (products?.length && onSwitchProduct)
    groups.push(
      <CommandGroup key="products" heading="Switch product">
        {products
          .filter((p) => p !== product)
          .map((p) => (
            <CommandItem key={p} value={`switch product ${PRODUCT_NAME[p]}`} onSelect={run(() => onSwitchProduct(p))}>
              <img src={PRODUCT_TILE[p]} alt="" className="size-4 rounded-xs" />
              {PRODUCT_NAME[p]}
            </CommandItem>
          ))}
      </CommandGroup>
    )

  if (onTheme)
    groups.push(
      <CommandGroup key="appearance" heading="Appearance">
        <CommandItem value="theme light appearance" onSelect={run(() => onTheme("light"))}>
          <Sun />
          Light theme
        </CommandItem>
        <CommandItem value="theme dark appearance" onSelect={run(() => onTheme("dark"))}>
          <Moon />
          Dark theme
        </CommandItem>
        <CommandItem value="theme high contrast accessibility appearance" onSelect={run(() => onTheme("contrast"))}>
          <Contrast />
          High contrast theme
        </CommandItem>
      </CommandGroup>
    )

  const visible = groups.filter(Boolean)

  return (
    <CommandDialog open={open} onOpenChange={onOpenChange} shouldFilter={shouldFilter}>
      <CommandInput placeholder={placeholder} scope={product ? PRODUCT_NAME[product].replace(/\.$/, "") : undefined} value={query} onValueChange={onQueryChange} />
      <CommandList>
        <CommandEmpty>{emptyText}</CommandEmpty>
        {visible.map((g, i) => (
          <React.Fragment key={i}>
            {i > 0 ? <CommandSeparator /> : null}
            {g}
          </React.Fragment>
        ))}
      </CommandList>
      <CommandFooter>
        <span className="flex items-center gap-1.5">
          <Kbd>↑</Kbd>
          <Kbd>↓</Kbd> move
        </span>
        <span className="flex items-center gap-1.5">
          <Kbd>
            <CornerDownLeft />
          </Kbd>{" "}
          open
        </span>
        <span className="flex items-center gap-1.5">
          <Kbd>⌘</Kbd>
          <Kbd>K</Kbd> toggle
        </span>
        <span className="ml-auto">jengacore</span>
      </CommandFooter>
    </CommandDialog>
  )
}