diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 8b67721..60b192b 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -26,6 +26,7 @@ import { import { PartyIcon } from "@hugeicons/core-free-icons" import { useUser } from "@/context/UserContext" import { useCelebration } from "@/hooks/useTaskCelebration" +import { useMetaHotkey } from "@/hooks/use-hotkey" interface CommandPaletteProps { open: boolean @@ -343,18 +344,7 @@ export function CommandPalette({ */ export function useCommandPalette() { const [open, setOpen] = useState(false) - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "k") { - e.preventDefault() - setOpen((prev) => !prev) - } - } - - document.addEventListener("keydown", handleKeyDown) - return () => document.removeEventListener("keydown", handleKeyDown) - }, []) - + const toggle = useCallback(() => setOpen((prev) => !prev), []) + useMetaHotkey("k", toggle) return { open, setOpen } } diff --git a/frontend/src/components/ShortcutHelpDialog.tsx b/frontend/src/components/ShortcutHelpDialog.tsx index ce78bfe..763d80d 100644 --- a/frontend/src/components/ShortcutHelpDialog.tsx +++ b/frontend/src/components/ShortcutHelpDialog.tsx @@ -6,39 +6,14 @@ import { DialogDescription, } from "@/components/ui/dialog" import { Kbd } from "@/components/ui/kbd" +import { groupShortcuts } from "@/lib/shortcut-registry" interface ShortcutHelpDialogProps { open: boolean onOpenChange: (open: boolean) => void } -const shortcuts = [ - { - group: "Global", - items: [ - { keys: ["⌘", "K"], description: "Open command palette" }, - { keys: ["?"], description: "Show keyboard shortcuts" }, - { keys: ["⇧", "T"], description: "Celebrate" }, - ], - }, - { - group: "Navigation", - items: [ - { keys: ["G", "D"], description: "Go to Dashboard" }, - { keys: ["G", "P"], description: "Go to Projects" }, - { keys: ["G", "T"], description: "Go to Tasks" }, - { keys: ["G", "M"], description: "Go to Team" }, - ], - }, - { - group: "Project Detail", - items: [{ keys: ["T"], description: "Create new task" }], - }, - { - group: "Task Detail", - items: [{ keys: ["A"], description: "Add assignee" }], - }, -] +const shortcuts = groupShortcuts() export function ShortcutHelpDialog({ open, @@ -62,7 +37,7 @@ export function ShortcutHelpDialog({
{group.items.map((shortcut) => (
{shortcut.description} diff --git a/frontend/src/components/TaskDetailSheet.tsx b/frontend/src/components/TaskDetailSheet.tsx index 186b8f6..20f2462 100644 --- a/frontend/src/components/TaskDetailSheet.tsx +++ b/frontend/src/components/TaskDetailSheet.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from "react" +import { useState, useEffect, useRef, useMemo, useCallback } from "react" import { useFrappeUpdateDoc, useFrappePostCall, useFrappeGetDocList, useFrappeGetDoc } from "frappe-react-sdk" import { Spinner } from "@/components/ui/spinner" import { format } from "date-fns" @@ -143,6 +143,15 @@ export function TaskDetailSheet({ task, open, onOpenChange, onUpdated, hasClient }, [allMembers, user?.email]) const [assigneePopoverOpen, setAssigneePopoverOpen] = useState(false) + const scrollRef = useRef(null) + + // Focus the scrollable container (not the title input) when sheet opens + // so single-key shortcuts work immediately. + // Two-phase: rAF catches most cases, setTimeout(50) beats base-ui's focus trap. + const focusContainer = useCallback(() => { + requestAnimationFrame(() => scrollRef.current?.focus()) + setTimeout(() => scrollRef.current?.focus(), 50) + }, []) // Only reset form when a different task opens (not on refetch of same task) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -181,24 +190,68 @@ export function TaskDetailSheet({ task, open, onOpenChange, onUpdated, hasClient const saveRef = useRef<() => void>(() => {}) // Cmd+Enter (Mac) / Ctrl+Enter (Windows/Linux) to save - // 'A' key to open assignee popover (when not typing in an input) + // 'S' key to open assignee popover (when not typing in an input) + // 'M then S' chord to assign task to self useEffect(() => { if (!open || isClient) return + let chordPending = false + let chordTimer: ReturnType const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault() saveRef.current() + return + } + const tag = (e.target as HTMLElement)?.tagName + const isEditable = tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable + if (e.metaKey || e.ctrlKey || e.altKey || isEditable) return + + const key = e.key.toLowerCase() + + // M→S chord: assign to self + if (chordPending) { + chordPending = false + clearTimeout(chordTimer) + if (key === "s" && user?.email && allMembers) { + e.preventDefault() + e.stopPropagation() + const me = allMembers.find((m) => m.user === user.email) + if (me) { + setAssignees((prev) => { + if (prev.some((a) => a.member === me.name)) return prev + return [...prev, { member: me.name, member_name: me.member_name, user_image: me.user_image }] + }) + markEdited() + toast.success("Assigned to you") + } + } + return } - if (e.key === "a" && !e.metaKey && !e.ctrlKey && !e.altKey) { - const tag = (e.target as HTMLElement)?.tagName - if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable) return + + if (key === "m") { + e.preventDefault() + e.stopPropagation() + chordPending = true + chordTimer = setTimeout(() => { chordPending = false }, 1000) + return + } + + if (key === "s") { e.preventDefault() setAssigneePopoverOpen(true) } } document.addEventListener("keydown", handleKeyDown) - return () => document.removeEventListener("keydown", handleKeyDown) - }, [open, isClient]) + return () => { + document.removeEventListener("keydown", handleKeyDown) + clearTimeout(chordTimer) + } + }, [open, isClient, user?.email, allMembers]) + + // Steal focus from the title input after the dialog's focus management runs + useEffect(() => { + if (open) focusContainer() + }, [open, focusContainer]) // Autosave: debounce 1.5s after user edits useEffect(() => { @@ -341,7 +394,7 @@ export function TaskDetailSheet({ task, open, onOpenChange, onUpdated, hasClient const assignedMemberNames = new Set(assignees.map((a) => a.member)) const formContent = ( -
+
{/* Title */}
diff --git a/frontend/src/hooks/use-hotkey.ts b/frontend/src/hooks/use-hotkey.ts index b2e12cc..08ddc38 100644 --- a/frontend/src/hooks/use-hotkey.ts +++ b/frontend/src/hooks/use-hotkey.ts @@ -90,3 +90,27 @@ export function useChordHotkey( } }, [leader, chords]) } + +/** + * Register a Cmd/Ctrl+key keyboard shortcut. + * Works in all contexts including inputs. + */ +export function useMetaHotkey( + key: string, + callback: () => void, + options?: { enabled?: boolean }, +) { + useEffect(() => { + if (options?.enabled === false) return + + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === key.toLowerCase()) { + e.preventDefault() + callback() + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [key, callback, options?.enabled]) +} diff --git a/frontend/src/lib/shortcut-registry.ts b/frontend/src/lib/shortcut-registry.ts new file mode 100644 index 0000000..5838342 --- /dev/null +++ b/frontend/src/lib/shortcut-registry.ts @@ -0,0 +1,44 @@ +export interface ShortcutDef { + id: string + keys: string[] + description: string + group: "Global" | "Navigation" | "Project Detail" | "Task Detail" +} + +export const SHORTCUT_REGISTRY: ShortcutDef[] = [ + // Global + { id: "cmd-k", keys: ["\u2318", "K"], description: "Open command palette", group: "Global" }, + { id: "help", keys: ["?"], description: "Show keyboard shortcuts", group: "Global" }, + { id: "celebrate", keys: ["\u21e7", "T"], description: "Celebrate", group: "Global" }, + + // Navigation + { id: "go-dashboard", keys: ["G", "D"], description: "Go to Dashboard", group: "Navigation" }, + { id: "go-projects", keys: ["G", "P"], description: "Go to Projects", group: "Navigation" }, + { id: "go-tasks", keys: ["G", "T"], description: "Go to Tasks", group: "Navigation" }, + { id: "go-team", keys: ["G", "M"], description: "Go to Team", group: "Navigation" }, + + // Project Detail + { id: "tab-overview", keys: ["O"], description: "Switch to Overview tab", group: "Project Detail" }, + { id: "tab-milestones", keys: ["M"], description: "Switch to Milestones tab", group: "Project Detail" }, + { id: "tab-updates", keys: ["U"], description: "Switch to Updates tab", group: "Project Detail" }, + { id: "tab-requests", keys: ["R"], description: "Switch to Requests tab", group: "Project Detail" }, + { id: "tab-activity", keys: ["A"], description: "Switch to Activity tab", group: "Project Detail" }, + { id: "create-task", keys: ["T"], description: "Create new task", group: "Project Detail" }, + + // Task Detail + { id: "add-assignee", keys: ["S"], description: "Add assignee", group: "Task Detail" }, + { id: "assign-self", keys: ["M", "S"], description: "Assign to myself", group: "Task Detail" }, +] + +export function groupShortcuts(): { group: string; items: ShortcutDef[] }[] { + const groups = new Map() + for (const shortcut of SHORTCUT_REGISTRY) { + const list = groups.get(shortcut.group) + if (list) { + list.push(shortcut) + } else { + groups.set(shortcut.group, [shortcut]) + } + } + return Array.from(groups, ([group, items]) => ({ group, items })) +} diff --git a/frontend/src/pages/ProjectDetailPage.tsx b/frontend/src/pages/ProjectDetailPage.tsx index 3e09d67..0efaa12 100644 --- a/frontend/src/pages/ProjectDetailPage.tsx +++ b/frontend/src/pages/ProjectDetailPage.tsx @@ -112,7 +112,20 @@ export function ProjectDetailPage() { const openCreateDialog = useCallback(() => { if (!isClient) setCreateOpen(true) }, [isClient]) - useHotkey("t", openCreateDialog) + useHotkey("t", openCreateDialog, { shift: false }) + + // Tab-switching shortcuts (disabled when dialogs/sheets are open) + const dialogOpen = sheetOpen || createOpen || createFeatureRequestOpen + const goOverview = useCallback(() => setActiveTab("overview"), []) + const goMilestones = useCallback(() => setActiveTab("milestones"), []) + const goUpdates = useCallback(() => setActiveTab("updates"), []) + const goRequests = useCallback(() => setActiveTab("requests"), []) + const goActivity = useCallback(() => setActiveTab("activity"), []) + useHotkey("o", goOverview, { enabled: !dialogOpen }) + useHotkey("m", goMilestones, { enabled: !dialogOpen }) + useHotkey("u", goUpdates, { enabled: !dialogOpen }) + useHotkey("r", goRequests, { enabled: !dialogOpen }) + useHotkey("a", goActivity, { enabled: !dialogOpen }) // Sync active tab from URL (e.g. ?tab=updates from dashboard click) const tabParam = searchParams.get("tab")