feat: MVP-5 Sprint 2 - Command Palette and Global Shortcuts
- Add Command Palette (Ctrl+K / Cmd+K) with search and navigation - Add global keyboard shortcuts: g h (dashboard), g n (notes), n (new note) - Add keyboard shortcuts help dialog (?) - Add shortcuts provider component - Shortcuts ignore inputs/textareas for proper UX
This commit is contained in:
79
src/components/command-palette.tsx
Normal file
79
src/components/command-palette.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { commands, CommandItem } from '@/lib/command-items'
|
||||
import { Search, FileText, Settings, Home, Plus } from 'lucide-react'
|
||||
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const router = useRouter()
|
||||
|
||||
// Listen for Ctrl+K / Cmd+K
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
// Filter commands by query
|
||||
const filtered = commands.filter(cmd =>
|
||||
cmd.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||
cmd.keywords?.some(k => k.includes(query.toLowerCase()))
|
||||
)
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
if (e.key === 'ArrowDown') setSelectedIndex(i => Math.min(i + 1, filtered.length - 1))
|
||||
if (e.key === 'ArrowUp') setSelectedIndex(i => Math.max(i - 1, 0))
|
||||
if (e.key === 'Enter' && filtered[selectedIndex]) {
|
||||
executeCommand(filtered[selectedIndex])
|
||||
}
|
||||
}
|
||||
|
||||
// Execute command
|
||||
const executeCommand = (cmd: CommandItem) => {
|
||||
router.push(cmd.id === 'action-new' ? '/new' : `/${cmd.id.split('-')[1]}`)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md p-0 gap-0">
|
||||
<div className="flex items-center border-b px-3">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar comandos..."
|
||||
className="border-0 focus-visible:ring-0"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{filtered.map((cmd, i) => (
|
||||
<button
|
||||
key={cmd.id}
|
||||
onClick={() => executeCommand(cmd)}
|
||||
className={`w-full px-3 py-2 text-left flex items-center gap-2 ${
|
||||
i === selectedIndex ? 'bg-muted' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm">{cmd.label}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{cmd.group}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
39
src/components/keyboard-shortcuts-dialog.tsx
Normal file
39
src/components/keyboard-shortcuts-dialog.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Keyboard } from 'lucide-react'
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ['n'], description: 'Nueva nota' },
|
||||
{ keys: ['g', 'h'], description: 'Ir al Dashboard' },
|
||||
{ keys: ['g', 'n'], description: 'Ir a Notas' },
|
||||
{ keys: ['/'], description: 'Enfocar búsqueda' },
|
||||
{ keys: ['?'], description: 'Mostrar atajos' },
|
||||
{ keys: ['Ctrl', 'K'], description: 'Command Palette' },
|
||||
]
|
||||
|
||||
export function KeyboardShortcutsDialog({ open, onOpenChange }: { open: boolean, onOpenChange: (o: boolean) => void }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Keyboard className="h-5 w-5" />
|
||||
Atajos de teclado
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="divide-y">
|
||||
{shortcuts.map((s) => (
|
||||
<div key={s.description} className="flex justify-between py-2">
|
||||
<span className="text-sm">{s.description}</span>
|
||||
<div className="flex gap-1">
|
||||
{s.keys.map((k) => (
|
||||
<kbd key={k} className="px-2 py-1 bg-muted rounded text-xs">{k}</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
12
src/components/shortcuts-provider.tsx
Normal file
12
src/components/shortcuts-provider.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useGlobalShortcuts } from '@/hooks/use-global-shortcuts'
|
||||
import { KeyboardShortcutsDialog } from '@/components/keyboard-shortcuts-dialog'
|
||||
|
||||
export function ShortcutsProvider() {
|
||||
const { showHelp, setShowHelp } = useGlobalShortcuts()
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsDialog open={showHelp} onOpenChange={setShowHelp} />
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user