Skip to content

Commit 9437457

Browse files
committed
feat(ui): add HanzoCommandPalette — shared Cmd+K command palette
Zero-dependency React component for cross-app command navigation. Supports extensible app-specific commands, keyboard nav, search filtering.
1 parent d203a1d commit 9437457

3 files changed

Lines changed: 280 additions & 2 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
'use client'
2+
3+
import { useState, useEffect, useRef, useCallback } from 'react'
4+
import { DEFAULT_HANZO_APPS, type HanzoApp } from './types'
5+
6+
// ---------------------------------------------------------------------------
7+
// Types
8+
// ---------------------------------------------------------------------------
9+
10+
export type CommandItem = {
11+
id: string
12+
title: string
13+
description?: string
14+
href?: string
15+
action?: () => void
16+
icon?: React.ReactNode
17+
category: string
18+
external?: boolean
19+
keywords?: string[]
20+
}
21+
22+
export type HanzoCommandPaletteProps = {
23+
/** Additional app-specific commands merged with built-in cross-app commands */
24+
commands?: CommandItem[]
25+
/** Override the default Hanzo apps used for cross-app navigation */
26+
apps?: HanzoApp[]
27+
/** Current app id — used to exclude from cross-app navigation */
28+
currentAppId?: string
29+
/** Controlled open state */
30+
open?: boolean
31+
/** Called when palette wants to close */
32+
onOpenChange?: (open: boolean) => void
33+
/** Custom navigation handler (defaults to window.location for external, history push for relative) */
34+
onNavigate?: (href: string, external?: boolean) => void
35+
}
36+
37+
// ---------------------------------------------------------------------------
38+
// Built-in cross-app commands derived from DEFAULT_HANZO_APPS
39+
// ---------------------------------------------------------------------------
40+
41+
function buildCrossAppCommands(apps: HanzoApp[], currentAppId?: string): CommandItem[] {
42+
return apps
43+
.filter((app) => app.id !== currentAppId)
44+
.map((app) => ({
45+
id: `app-${app.id}`,
46+
title: app.label,
47+
description: app.description,
48+
href: app.href,
49+
category: 'Hanzo Apps',
50+
external: true,
51+
keywords: [app.id, app.label.toLowerCase()],
52+
}))
53+
}
54+
55+
// ---------------------------------------------------------------------------
56+
// Component
57+
// ---------------------------------------------------------------------------
58+
59+
export function HanzoCommandPalette({
60+
commands: appCommands = [],
61+
apps,
62+
currentAppId,
63+
open: controlledOpen,
64+
onOpenChange,
65+
onNavigate,
66+
}: HanzoCommandPaletteProps) {
67+
const [internalOpen, setInternalOpen] = useState(false)
68+
const open = controlledOpen ?? internalOpen
69+
const setOpen = useCallback(
70+
(v: boolean) => {
71+
if (onOpenChange) onOpenChange(v)
72+
else setInternalOpen(v)
73+
},
74+
[onOpenChange],
75+
)
76+
77+
const [search, setSearch] = useState('')
78+
const [selectedIndex, setSelectedIndex] = useState(0)
79+
const inputRef = useRef<HTMLInputElement>(null)
80+
const listRef = useRef<HTMLDivElement>(null)
81+
82+
// Merge cross-app + app-specific commands
83+
const crossApp = buildCrossAppCommands(apps ?? DEFAULT_HANZO_APPS, currentAppId)
84+
const allCommands = [...appCommands, ...crossApp]
85+
86+
// Filter
87+
const q = search.toLowerCase()
88+
const filtered = q
89+
? allCommands.filter(
90+
(cmd) =>
91+
cmd.title.toLowerCase().includes(q) ||
92+
cmd.description?.toLowerCase().includes(q) ||
93+
cmd.keywords?.some((k) => k.includes(q)),
94+
)
95+
: allCommands
96+
97+
// Group by category
98+
const grouped: Record<string, CommandItem[]> = {}
99+
for (const cmd of filtered) {
100+
;(grouped[cmd.category] ??= []).push(cmd)
101+
}
102+
const flat = Object.values(grouped).flat()
103+
104+
// Reset on search change
105+
useEffect(() => setSelectedIndex(0), [search])
106+
107+
// Focus on open
108+
useEffect(() => {
109+
if (open) {
110+
setSearch('')
111+
setSelectedIndex(0)
112+
requestAnimationFrame(() => inputRef.current?.focus())
113+
}
114+
}, [open])
115+
116+
// Global Cmd+K
117+
useEffect(() => {
118+
const handler = (e: KeyboardEvent) => {
119+
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
120+
e.preventDefault()
121+
setOpen(!open)
122+
}
123+
}
124+
document.addEventListener('keydown', handler)
125+
return () => document.removeEventListener('keydown', handler)
126+
}, [open, setOpen])
127+
128+
// Navigate helper
129+
const go = useCallback(
130+
(cmd: CommandItem) => {
131+
if (cmd.action) {
132+
cmd.action()
133+
} else if (cmd.href) {
134+
if (onNavigate) {
135+
onNavigate(cmd.href, cmd.external)
136+
} else if (cmd.external) {
137+
window.open(cmd.href, '_blank')
138+
} else {
139+
window.location.href = cmd.href
140+
}
141+
}
142+
setOpen(false)
143+
},
144+
[onNavigate, setOpen],
145+
)
146+
147+
// Keyboard nav
148+
const handleKeyDown = useCallback(
149+
(e: React.KeyboardEvent) => {
150+
if (e.key === 'ArrowDown') {
151+
e.preventDefault()
152+
setSelectedIndex((i) => (i + 1) % (flat.length || 1))
153+
} else if (e.key === 'ArrowUp') {
154+
e.preventDefault()
155+
setSelectedIndex((i) => (i - 1 + (flat.length || 1)) % (flat.length || 1))
156+
} else if (e.key === 'Enter' && flat[selectedIndex]) {
157+
e.preventDefault()
158+
go(flat[selectedIndex])
159+
} else if (e.key === 'Escape') {
160+
setOpen(false)
161+
}
162+
},
163+
[flat, selectedIndex, go, setOpen],
164+
)
165+
166+
// Scroll selected into view
167+
useEffect(() => {
168+
const el = listRef.current?.querySelector(`[data-idx="${selectedIndex}"]`)
169+
el?.scrollIntoView({ block: 'nearest' })
170+
}, [selectedIndex])
171+
172+
if (!open) return null
173+
174+
return (
175+
<>
176+
{/* Backdrop */}
177+
<div
178+
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100]"
179+
onClick={() => setOpen(false)}
180+
/>
181+
182+
{/* Palette */}
183+
<div className="fixed top-[15%] left-1/2 -translate-x-1/2 w-full max-w-xl z-[101]">
184+
<div className="bg-[#111113] border border-white/[0.08] rounded-xl shadow-2xl overflow-hidden">
185+
{/* Search */}
186+
<div className="flex items-center gap-3 px-4 py-3 border-b border-white/[0.07]">
187+
<svg
188+
className="w-4 h-4 text-white/30"
189+
fill="none"
190+
viewBox="0 0 24 24"
191+
stroke="currentColor"
192+
strokeWidth={2}
193+
>
194+
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
195+
</svg>
196+
<input
197+
ref={inputRef}
198+
type="text"
199+
value={search}
200+
onChange={(e) => setSearch(e.target.value)}
201+
onKeyDown={handleKeyDown}
202+
placeholder="Search commands..."
203+
className="flex-1 bg-transparent text-white text-[13px] placeholder-white/30 outline-none"
204+
/>
205+
<kbd className="px-1.5 py-0.5 text-[10px] font-mono bg-white/[0.06] rounded text-white/30">
206+
ESC
207+
</kbd>
208+
</div>
209+
210+
{/* Results */}
211+
<div ref={listRef} className="max-h-[400px] overflow-y-auto py-1">
212+
{flat.length === 0 ? (
213+
<div className="px-4 py-8 text-center text-white/30 text-[13px]">
214+
No results for &ldquo;{search}&rdquo;
215+
</div>
216+
) : (
217+
Object.entries(grouped).map(([category, items]) => (
218+
<div key={category}>
219+
<div className="px-4 py-2 text-[10px] font-semibold text-white/25 uppercase tracking-widest">
220+
{category}
221+
</div>
222+
{items.map((cmd) => {
223+
const idx = flat.indexOf(cmd)
224+
const selected = idx === selectedIndex
225+
return (
226+
<button
227+
key={cmd.id}
228+
data-idx={idx}
229+
onClick={() => go(cmd)}
230+
onMouseEnter={() => setSelectedIndex(idx)}
231+
className={`w-full flex items-center gap-3 px-4 py-2 text-left transition-colors ${
232+
selected ? 'bg-white/[0.06] text-white' : 'text-white/50 hover:bg-white/[0.03]'
233+
}`}
234+
>
235+
{cmd.icon && (
236+
<span className={`w-5 h-5 flex items-center justify-center ${selected ? 'text-white/70' : 'text-white/25'}`}>
237+
{cmd.icon}
238+
</span>
239+
)}
240+
<div className="flex-1 min-w-0">
241+
<span className="text-[13px] font-medium truncate block">{cmd.title}</span>
242+
{cmd.description && (
243+
<span className="text-[11px] text-white/25 truncate block">{cmd.description}</span>
244+
)}
245+
</div>
246+
{selected && (
247+
<span className="text-white/25 text-[11px]"></span>
248+
)}
249+
</button>
250+
)
251+
})}
252+
</div>
253+
))
254+
)}
255+
</div>
256+
257+
{/* Footer */}
258+
<div className="px-4 py-2 border-t border-white/[0.07] flex items-center justify-between">
259+
<div className="flex items-center gap-4 text-[10px] text-white/20">
260+
<span className="flex items-center gap-1">
261+
<kbd className="px-1 py-0.5 bg-white/[0.06] rounded text-[9px]"></kbd>
262+
<kbd className="px-1 py-0.5 bg-white/[0.06] rounded text-[9px]"></kbd>
263+
navigate
264+
</span>
265+
<span className="flex items-center gap-1">
266+
<kbd className="px-1 py-0.5 bg-white/[0.06] rounded text-[9px]"></kbd>
267+
select
268+
</span>
269+
</div>
270+
<span className="text-[10px] text-white/20">⌘K</span>
271+
</div>
272+
</div>
273+
</div>
274+
</>
275+
)
276+
}

pkg/ui/src/navigation/hanzo-shell/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,7 @@ export { HanzoMark } from './HanzoMark'
33
export { AppSwitcher } from './AppSwitcher'
44
export { UserOrgDropdown } from './UserOrgDropdown'
55
export { useHanzoAuth } from './useHanzoAuth'
6+
export { HanzoCommandPalette } from './HanzoCommandPalette'
7+
export type { CommandItem as HanzoCommandItem, HanzoCommandPaletteProps } from './HanzoCommandPalette'
68
export type { HanzoApp, HanzoOrg, HanzoUser, HanzoShellProps, HanzoMarkProps } from './types'
79
export { DEFAULT_HANZO_APPS } from './types'

pkg/ui/src/navigation/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Hanzo Shell – shared header/nav for billing, account, console, chat, platform
2-
export { HanzoHeader, AppSwitcher, UserOrgDropdown, useHanzoAuth, DEFAULT_HANZO_APPS } from './hanzo-shell'
3-
export type { HanzoApp, HanzoOrg, HanzoUser, HanzoShellProps } from './hanzo-shell'
2+
export { HanzoHeader, AppSwitcher, UserOrgDropdown, useHanzoAuth, HanzoCommandPalette, DEFAULT_HANZO_APPS } from './hanzo-shell'
3+
export type { HanzoApp, HanzoOrg, HanzoUser, HanzoShellProps, HanzoCommandItem, HanzoCommandPaletteProps } from './hanzo-shell'
44

55
// Navigation bar components
66
export { default as AdvancedNavigationBar } from "./advanced-navigation-bar"

0 commit comments

Comments
 (0)