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-paletteWithout the namespace configured: npx shadcn@latest add https://ui.jengacore.com/r/<new-york-v4|base-nova>/command-palette.json
Registry dependencies
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>
)
}