diff --git a/apps/web/src/components/ReactiveButton.tsx b/apps/web/src/components/ReactiveButton.tsx
index 73340f73..5fddaeee 100644
--- a/apps/web/src/components/ReactiveButton.tsx
+++ b/apps/web/src/components/ReactiveButton.tsx
@@ -2,8 +2,10 @@ import Link from "next/link";
import { useState } from "react";
import { twMerge } from "tailwind-merge";
+import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
import LottieIcon from "~/components/LottieIcon";
import { useIsMobile } from "~/hooks/useMediaQuery";
+import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
const Button: React.FC<{
href: string;
@@ -12,10 +14,20 @@ const Button: React.FC<{
json: object;
isCollapsed?: boolean;
onCloseSideNav?: () => void;
-}> = ({ href, current, name, json, isCollapsed = false, onCloseSideNav }) => {
+ keyboardShortcut: KeyboardShortcut;
+}> = ({
+ href,
+ current,
+ name,
+ json,
+ isCollapsed = false,
+ keyboardShortcut,
+ onCloseSideNav,
+}) => {
const [isHovered, setIsHovered] = useState(false);
const [index, setIndex] = useState(0);
const isMobile = useIsMobile();
+ const { keys: shortcutKeys } = useKeyboardShortcut(keyboardShortcut);
const handleMouseEnter = () => {
setIsHovered(true);
@@ -34,18 +46,27 @@ const Button: React.FC<{
onMouseEnter={handleMouseEnter}
onClick={handleClick}
className={twMerge(
- "group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
+ "group flex h-[34px] items-center justify-between rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
current
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
- isCollapsed
- ? "justify-start gap-x-3 md:justify-center md:gap-x-0"
- : "gap-x-3",
)}
title={isCollapsed ? name : undefined}
>
-
- {name}
+
+
+ {name}
+
+ {!isCollapsed && (
+ {shortcutKeys}
+ )}
);
};
diff --git a/apps/web/src/components/SideNavigation.tsx b/apps/web/src/components/SideNavigation.tsx
index 68383de6..7b9361dd 100644
--- a/apps/web/src/components/SideNavigation.tsx
+++ b/apps/web/src/components/SideNavigation.tsx
@@ -15,6 +15,7 @@ import { twMerge } from "tailwind-merge";
import type { Subscription } from "@kan/shared/utils";
import { hasActiveSubscription } from "@kan/shared/utils";
+import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
import boardsIconDark from "~/assets/boards-dark.json";
import boardsIconLight from "~/assets/boards-light.json";
import membersIconDark from "~/assets/members-dark.json";
@@ -86,26 +87,59 @@ export default function SideNavigation({
const isDarkMode = resolvedTheme === "dark";
- const navigation = [
+ const navigation: {
+ name: string;
+ href: string;
+ icon: object;
+ keyboardShortcut: KeyboardShortcut;
+ }[] = [
{
name: t`Boards`,
href: "/boards",
icon: isDarkMode ? boardsIconDark : boardsIconLight,
+ keyboardShortcut: {
+ type: "SEQUENCE",
+ strokes: [{ key: "G" }, { key: "B" }],
+ action: () => router.push("/boards"),
+ group: "NAVIGATION",
+ description: t`Go to boards`,
+ },
},
{
name: t`Templates`,
href: "/templates",
icon: isDarkMode ? templatesIconDark : templatesIconLight,
+ keyboardShortcut: {
+ type: "SEQUENCE",
+ strokes: [{ key: "G" }, { key: "T" }],
+ action: () => router.push("/templates"),
+ group: "NAVIGATION",
+ description: t`Go to templates`,
+ },
},
{
name: t`Members`,
href: "/members",
icon: isDarkMode ? membersIconDark : membersIconLight,
+ keyboardShortcut: {
+ type: "SEQUENCE",
+ strokes: [{ key: "G" }, { key: "M" }],
+ action: () => router.push("/members"),
+ group: "NAVIGATION",
+ description: t`Go to members`,
+ },
},
{
name: t`Settings`,
href: "/settings",
icon: isDarkMode ? settingsIconDark : settingsIconLight,
+ keyboardShortcut: {
+ type: "SEQUENCE",
+ strokes: [{ key: "G" }, { key: "S" }],
+ action: () => router.push("/settings"),
+ group: "NAVIGATION",
+ description: t`Go to settings`,
+ },
},
];
@@ -163,6 +197,7 @@ export default function SideNavigation({
json={item.icon}
isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
+ keyboardShortcut={item.keyboardShortcut}
/>
))}
diff --git a/apps/web/src/components/Tooltip.tsx b/apps/web/src/components/Tooltip.tsx
new file mode 100644
index 00000000..a6a30ba5
--- /dev/null
+++ b/apps/web/src/components/Tooltip.tsx
@@ -0,0 +1,51 @@
+import type { ReactNode } from "react";
+import type { Root } from "react-dom/client";
+import type { Placement } from "tippy.js";
+import { useEffect, useRef } from "react";
+import { createRoot } from "react-dom/client";
+import tippy from "tippy.js";
+
+interface TooltipProps {
+ children: ReactNode;
+ content: ReactNode;
+ placement?: Placement;
+ delay?: number | [number, number];
+}
+
+export function Tooltip({
+ children,
+ content,
+ placement = "bottom",
+ delay = [500, 0],
+}: TooltipProps) {
+ const triggerRef = useRef(null);
+ const rootRef = useRef(null);
+
+ useEffect(() => {
+ if (!triggerRef.current) return;
+
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ rootRef.current = root;
+ root.render(content);
+
+ const instance = tippy(triggerRef.current, {
+ content: container,
+ placement,
+ delay,
+ interactive: false,
+ theme: "tooltip",
+ });
+
+ return () => {
+ instance.destroy();
+ rootRef.current?.unmount();
+ };
+ }, [content, placement, delay]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/UserMenu.tsx b/apps/web/src/components/UserMenu.tsx
index 5d53cf03..0a483385 100644
--- a/apps/web/src/components/UserMenu.tsx
+++ b/apps/web/src/components/UserMenu.tsx
@@ -10,6 +10,7 @@ import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import { useIsMobile } from "~/hooks/useMediaQuery";
+import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { getAvatarUrl } from "~/utils/helpers";
@@ -31,6 +32,7 @@ export default function UserMenu({
const router = useRouter();
const { theme, setTheme } = useTheme();
const { openModal } = useModal();
+ const { openLegend } = useKeyboardShortcuts();
const isMobile = useIsMobile();
const handleLogout = async () => {
@@ -169,6 +171,19 @@ export default function UserMenu({
+
+
+
{
- const handleKeyDown = (event: KeyboardEvent) => {
- if ((event.metaKey || event.ctrlKey) && event.key === "k") {
- event.preventDefault();
- setIsOpen(true);
- }
- };
-
- document.addEventListener("keydown", handleKeyDown);
- return () => document.removeEventListener("keydown", handleKeyDown);
- }, []);
+ const { tooltipContent: commandPaletteShortcutTooltipContent } =
+ useKeyboardShortcut({
+ type: "PRESS",
+ stroke: {
+ key: "k",
+ modifiers: ["META"],
+ },
+ action: () => setIsOpen(true),
+ description: t`Open command menu`,
+ group: "GENERAL",
+ });
return (
<>
@@ -84,15 +86,17 @@ export default function WorkspaceMenu({
)}
-
+
+
+
)}
diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx
index 7dc7ea90..7379cd8e 100644
--- a/apps/web/src/pages/_app.tsx
+++ b/apps/web/src/pages/_app.tsx
@@ -12,6 +12,7 @@ import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react";
+import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
import { LinguiProviderWrapper } from "~/providers/lingui";
import { ModalProvider } from "~/providers/modal";
import { PopupProvider } from "~/providers/popup";
@@ -80,21 +81,23 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
)}
-
-
-
-
- {posthogKey ? (
-
- {getLayout()}
-
- ) : (
- getLayout()
- )}
-
-
-
-
+
+
+
+
+
+ {posthogKey ? (
+
+ {getLayout()}
+
+ ) : (
+ getLayout()
+ )}
+
+
+
+
+
>
);
diff --git a/apps/web/src/providers/keyboard-shortcuts.tsx b/apps/web/src/providers/keyboard-shortcuts.tsx
new file mode 100644
index 00000000..089dea32
--- /dev/null
+++ b/apps/web/src/providers/keyboard-shortcuts.tsx
@@ -0,0 +1,592 @@
+import type { ReactNode } from "react";
+import {
+ Dialog,
+ DialogBackdrop,
+ DialogPanel,
+ DialogTitle,
+} from "@headlessui/react";
+import { t } from "@lingui/core/macro";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { HiXMark } from "react-icons/hi2";
+
+import { env } from "~/env";
+import { useEventListener } from "~/hooks/useEventListener";
+
+const ModifierKey = {
+ CONTROL: "CONTROL",
+ META: "META",
+ ALT: "ALT",
+ SHIFT: "SHIFT",
+} as const;
+type ModifierKey = (typeof ModifierKey)[keyof typeof ModifierKey];
+
+const ModifierKeyInfo: Record<
+ ModifierKey,
+ {
+ macSymbol: string;
+ winName: string;
+ linuxName: string;
+ }
+> = {
+ CONTROL: { macSymbol: "⌃", winName: "Ctrl", linuxName: "Ctrl" },
+ META: { macSymbol: "⌘", winName: "Win", linuxName: "Super" },
+ ALT: { macSymbol: "⌥", winName: "Alt", linuxName: "Alt" },
+ SHIFT: { macSymbol: "⇧", winName: "Shift", linuxName: "Shift" },
+};
+
+const ShortcutGroup = {
+ GENERAL: "GENERAL",
+ NAVIGATION: "NAVIGATION",
+ ACTIONS: "ACTIONS",
+} as const;
+type ShortcutGroup = (typeof ShortcutGroup)[keyof typeof ShortcutGroup];
+
+const getShortcutGroupInfo = (): Record => ({
+ GENERAL: { label: t`General` },
+ NAVIGATION: { label: t`Navigation` },
+ ACTIONS: { label: t`Actions` },
+});
+
+interface KeyStroke {
+ key: string;
+ modifiers?: ModifierKey[];
+}
+
+interface Press {
+ type: "PRESS";
+ stroke: KeyStroke;
+}
+
+interface Sequence {
+ type: "SEQUENCE";
+ strokes: KeyStroke[];
+}
+
+export type KeyboardShortcut = {
+ action: () => void;
+ description: string;
+ group: ShortcutGroup;
+} & (Press | Sequence);
+
+interface ShortcutTreeStepNode {
+ type: "STEP";
+ children: ShortcutTreeLevel;
+}
+interface ShortcutTreeActionNode {
+ type: "ACTION";
+ shortcut: KeyboardShortcut;
+}
+type ShortcutTreeNode = ShortcutTreeStepNode | ShortcutTreeActionNode;
+type ShortcutTreeLevel = Record;
+
+const SEQUENCE_TIMEOUT_MS = 1000;
+
+const ShortcutConflictCode = {
+ ACTION_ON_SEQUENCE: "ACTION_ON_SEQUENCE",
+ DUPLICATE_ACTION: "DUPLICATE_ACTION",
+ SEQUENCE_ON_ACTION: "SEQUENCE_ON_ACTION",
+} as const;
+type ShortcutConflictCode = keyof typeof ShortcutConflictCode;
+
+interface ShortcutConflictErrorOptions {
+ code: ShortcutConflictCode;
+ shortcut: KeyboardShortcut;
+ conflictPath: string;
+ existingShortcut?: KeyboardShortcut;
+}
+
+class ShortcutConflictError extends Error {
+ public readonly code: ShortcutConflictCode;
+ public readonly shortcut: KeyboardShortcut;
+ public readonly conflictPath: string;
+ public readonly existingShortcut?: KeyboardShortcut;
+
+ constructor(options: ShortcutConflictErrorOptions) {
+ super(ShortcutConflictError.formatMessage(options));
+ this.name = "ShortcutConflictError";
+ this.code = options.code;
+ this.shortcut = options.shortcut;
+ this.conflictPath = options.conflictPath;
+ this.existingShortcut = options.existingShortcut;
+ }
+
+ private static stringifyShortcut(shortcut: KeyboardShortcut): string {
+ if (shortcut.type === "SEQUENCE") {
+ return shortcut.strokes.map(serializeKeyStroke).join(" → ");
+ }
+ return serializeKeyStroke(shortcut.stroke);
+ }
+
+ private static formatMessage(options: ShortcutConflictErrorOptions): string {
+ const formatted = ShortcutConflictError.stringifyShortcut(options.shortcut);
+ switch (options.code) {
+ case ShortcutConflictCode.ACTION_ON_SEQUENCE:
+ return `Cannot register "${formatted}": desired action conflicts with existing sequence at "${options.conflictPath}"`;
+ case ShortcutConflictCode.DUPLICATE_ACTION:
+ return `Cannot register "${formatted}": action already registered`;
+ case ShortcutConflictCode.SEQUENCE_ON_ACTION:
+ return `Cannot register "${formatted}": desired sequence conflicts with existing action at "${options.conflictPath}"`;
+ }
+ }
+}
+
+interface KeyboardShortcutContextType {
+ registerShortcut: (shortcut: KeyboardShortcut) => () => void;
+ openLegend: () => void;
+ openLegendKeys: ReactNode;
+}
+
+const KeyboardShortcutContext = createContext<
+ KeyboardShortcutContextType | undefined
+>(undefined);
+
+export function KeyboardShortcutProvider({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ const treeRootRef = useRef({});
+ const currentNodeRef = useRef(treeRootRef.current);
+ const shortcutsRef = useRef