feat: add support for keyboard shortcuts (#255)

* feat: add support for keyboard shortcuts

* fix: recenter icons on button

* feat: extend tooltip delay

* chore: add translations

* refactor: tweak kbd styling

* feat: move shortcuts to user menu

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
Spencer Simpson
2025-11-27 15:09:37 -07:00
committed by GitHub
parent c5d85cdb2d
commit adbc945ed6
10 changed files with 829 additions and 68 deletions

View File

@@ -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}
>
<LottieIcon index={index} json={json} isPlaying={isHovered} />
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
<div
className={twMerge(
"flex items-center",
isCollapsed
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
: "gap-x-3",
)}
>
<LottieIcon index={index} json={json} isPlaying={isHovered} />
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
</div>
{!isCollapsed && (
<div className="hidden md:group-hover:inline-flex">{shortcutKeys}</div>
)}
</Link>
);
};

View File

@@ -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}
/>
</li>
))}

View File

@@ -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<HTMLDivElement>(null);
const rootRef = useRef<Root | null>(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 (
<div ref={triggerRef} className="inline-flex">
{children}
</div>
);
}

View File

@@ -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({
</Menu.Item>
</div>
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
<Menu.Item>
<button
onClick={() => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
openLegend();
}}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
>
{t`Shortcuts`}
</button>
</Menu.Item>
<Menu.Item>
<Link
href="mailto:support@kan.bn"

View File

@@ -1,12 +1,14 @@
import { Button, Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment, useEffect, useState } from "react";
import { Fragment, useState } from "react";
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import CommandPallette from "./CommandPallette";
import { Tooltip } from "./Tooltip";
export default function WorkspaceMenu({
isCollapsed = false,
@@ -18,17 +20,17 @@ export default function WorkspaceMenu({
const { openModal } = useModal();
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
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({
</span>
)}
</Menu.Button>
<Button
className={twMerge(
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
isCollapsed && "md:mb-2 md:h-9 md:w-9",
)}
onClick={() => setIsOpen(true)}
>
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
</Button>
<Tooltip content={commandPaletteShortcutTooltipContent}>
<Button
className={twMerge(
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
isCollapsed && "md:mb-2 md:h-9 md:w-9",
)}
onClick={() => setIsOpen(true)}
>
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
</Button>
</Tooltip>
</div>
)}
</div>

View File

@@ -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) => {
)}
<script src="/__ENV.js" />
<main className="font-sans">
<LinguiProviderWrapper>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ModalProvider>
<PopupProvider>
{posthogKey ? (
<PostHogProvider client={posthog}>
{getLayout(<Component {...pageProps} />)}
</PostHogProvider>
) : (
getLayout(<Component {...pageProps} />)
)}
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</LinguiProviderWrapper>
<KeyboardShortcutProvider>
<LinguiProviderWrapper>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ModalProvider>
<PopupProvider>
{posthogKey ? (
<PostHogProvider client={posthog}>
{getLayout(<Component {...pageProps} />)}
</PostHogProvider>
) : (
getLayout(<Component {...pageProps} />)
)}
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</LinguiProviderWrapper>
</KeyboardShortcutProvider>
</main>
</>
);

View File

@@ -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<ShortcutGroup, { label: string }> => ({
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<string, ShortcutTreeNode>;
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<ShortcutTreeLevel>({});
const currentNodeRef = useRef<ShortcutTreeLevel>(treeRootRef.current);
const shortcutsRef = useRef<Map<string, KeyboardShortcut>>(new Map());
const sequenceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isLegendOpen, setIsLegendOpen] = useState(false);
const openLegendShortcut: KeyboardShortcut = useMemo(
() => ({
type: "PRESS",
stroke: {
key: "/",
modifiers: ["META"],
},
action: () => setIsLegendOpen(true),
description: t`Open keyboard shortcuts`,
group: ShortcutGroup.GENERAL,
}),
[setIsLegendOpen],
);
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (isTypingInInput(event)) {
return;
}
if (sequenceTimeoutRef.current) {
clearTimeout(sequenceTimeoutRef.current);
sequenceTimeoutRef.current = null;
}
const serializedKey = serializeEvent(event);
const node = currentNodeRef.current[serializedKey];
if (!node) {
currentNodeRef.current = treeRootRef.current;
return;
}
switch (node.type) {
case "STEP":
event.preventDefault();
currentNodeRef.current = node.children;
sequenceTimeoutRef.current = setTimeout(() => {
currentNodeRef.current = treeRootRef.current;
}, SEQUENCE_TIMEOUT_MS);
return;
case "ACTION":
event.preventDefault();
node.shortcut.action();
currentNodeRef.current = treeRootRef.current;
return;
default:
return;
}
}, []);
const registerShortcut = useCallback(
(shortcut: KeyboardShortcut): (() => void) => {
const strokes =
shortcut.type === "SEQUENCE" ? shortcut.strokes : [shortcut.stroke];
const path = strokes.map(serializeKeyStroke);
const pathKey = path.join(" → ");
path.reduce((currentLevel, key, i) => {
const isLast = i === path.length - 1;
const existingNode = currentLevel[key];
if (env.NODE_ENV === "development" && existingNode) {
const conflictPath = path.slice(0, i + 1).join(" → ");
validateNoConflict(existingNode, isLast, shortcut, conflictPath);
}
if (isLast) {
currentLevel[key] = {
type: "ACTION",
shortcut,
};
return currentLevel;
}
if (existingNode?.type === "STEP") {
return existingNode.children;
}
const newNode: ShortcutTreeStepNode = {
type: "STEP",
children: {},
};
currentLevel[key] = newNode;
return newNode.children;
}, treeRootRef.current);
shortcutsRef.current.set(pathKey, shortcut);
return () => {
removePathAndPrune(treeRootRef.current, path);
shortcutsRef.current.delete(pathKey);
};
},
[],
);
useEventListener("keydown", handleKeyDown);
// Register built-in shortcut to open legend
useEffect(() => {
const cleanup = registerShortcut(openLegendShortcut);
return cleanup;
}, [registerShortcut, openLegendShortcut]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (sequenceTimeoutRef.current) {
clearTimeout(sequenceTimeoutRef.current);
}
};
}, []);
const shortcutsArray = Array.from(shortcutsRef.current.values());
const groupedShortcuts = shortcutsArray.reduce<
Partial<Record<ShortcutGroup, KeyboardShortcut[]>>
>((acc, shortcut) => {
const group = shortcut.group;
acc[group] ??= [];
acc[group].push(shortcut);
return acc;
}, {});
const openLegend = useCallback(() => {
setIsLegendOpen(true);
}, []);
const openLegendKeys = useMemo(
() => <FormattedShortcut shortcut={openLegendShortcut} />,
[openLegendShortcut],
);
return (
<KeyboardShortcutContext.Provider
value={{ registerShortcut, openLegend, openLegendKeys }}
>
{children}
{/* Shortcut Legend */}
<Dialog
className="relative z-50"
open={isLegendOpen}
onClose={() => setIsLegendOpen(false)}
>
<DialogBackdrop
transition
className="data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40"
/>
<div className="fixed inset-0 flex min-h-full w-screen items-center justify-center overflow-y-auto p-4">
<DialogPanel
transition
className="relative w-full max-w-sm transform overflow-hidden rounded-lg border border-light-600 bg-white shadow-3xl-light dark:border-dark-600 dark:bg-dark-100 dark:shadow-3xl-dark"
>
<div className="flex items-center justify-between border-b border-light-300 px-6 py-4 dark:border-dark-300">
<DialogTitle className="text-[14px] font-semibold text-neutral-900 dark:text-dark-1000">
{t`Keyboard Shortcuts`}
</DialogTitle>
<button
onClick={() => setIsLegendOpen(false)}
className="rounded p-1 hover:bg-light-200 dark:hover:bg-dark-200"
>
<HiXMark className="h-5 w-5 text-neutral-700 dark:text-dark-700" />
</button>
</div>
<div className="max-h-[60vh] overflow-y-auto p-6">
{shortcutsArray.length === 0 ? (
<p className="text-center text-sm text-neutral-600 dark:text-dark-600">
{t`No keyboard shortcuts registered.`}
</p>
) : (
<div className="space-y-6">
{Object.values(ShortcutGroup).map((group, idx) => {
const shortcuts = groupedShortcuts[group];
if (!shortcuts?.length) return null;
const groupInfo = getShortcutGroupInfo();
return (
<div key={`${group}-${idx}`}>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-light-1000 dark:text-dark-1000">
{groupInfo[group].label}
</h3>
<div className="flex flex-col gap-y-2">
{shortcuts.map((shortcut) => (
<ShortcutListItem
key={shortcut.description}
shortcut={shortcut}
/>
))}
</div>
</div>
);
})}
</div>
)}
</div>
</DialogPanel>
</div>
</Dialog>
</KeyboardShortcutContext.Provider>
);
}
/**
* Hook to register a keyboard shortcut. Cleanup is handled automatically.
* Returns both formatted keys and pre-built tooltip content.
*
* IMPORTANT: The shortcut object reference must be stable. If it changes
* on every render, the shortcut will be continuously registered and unregistered.
*
* @example
* ```tsx
* const { keys, tooltipContent } = useKeyboardShortcut({
* type: "PRESS",
* stroke: { key: "k", modifiers: ["META"] },
* action: () => openCommandPalette(),
* description: "Open command palette",
* group: "GENERAL"
* });
*
* // Use tooltip for Tooltip component
* <Tooltip content={tooltipContent}>
* <button>Search</button>
* </Tooltip>
*
* // Or use keys directly for custom formatting
* <span>Press {keys} to search</span>
* ```
*/
export function useKeyboardShortcuts(): KeyboardShortcutContextType {
const context = useContext(KeyboardShortcutContext);
if (!context) {
throw new Error(
"useKeyboardShortcuts must be used within KeyboardShortcutProvider",
);
}
return context;
}
export function useKeyboardShortcut(shortcut: KeyboardShortcut): {
keys: ReactNode;
tooltipContent: ReactNode;
} {
const context = useContext(KeyboardShortcutContext);
if (!context) {
throw new Error(
"useKeyboardShortcut must be used within KeyboardShortcutProvider",
);
}
const { registerShortcut } = context;
useEffect(() => {
const cleanup = registerShortcut(shortcut);
return cleanup;
}, [shortcut, registerShortcut]);
const keys = <FormattedShortcut shortcut={shortcut} />;
const tooltipContent = (
<div className="flex flex-row items-center gap-2 text-[11px]">
{shortcut.description} {keys}
</div>
);
return { keys, tooltipContent };
}
function ShortcutListItem({ shortcut }: { shortcut: KeyboardShortcut }) {
return (
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-dark-50 dark:text-dark-900">
{shortcut.description}
</span>
<FormattedShortcut shortcut={shortcut} />
</div>
);
}
function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
const kbdClassName =
"inline-flex h-5 w-5 items-center justify-center rounded border border-light-400 bg-light-200 px-1.5 py-0.5 font-mono text-[8px] font-semibold text-center text-neutral-900 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-950";
const stringifyModifier = (modifier: ModifierKey): string => {
const isMac =
typeof navigator !== "undefined" && navigator.userAgent.includes("Mac");
const isLinux =
typeof navigator !== "undefined" && navigator.userAgent.includes("Linux");
const info = ModifierKeyInfo[modifier];
if (isMac) return info.macSymbol;
if (isLinux) return info.linuxName;
return info.winName;
};
const formatStroke = (stroke: KeyStroke): ReactNode[] => {
const parts: ReactNode[] = [];
const modifierStrings = stroke.modifiers
? stroke.modifiers.map(stringifyModifier)
: [];
modifierStrings.forEach((mod) => {
parts.push(<kbd className={kbdClassName}>{mod}</kbd>);
});
parts.push(<kbd className={kbdClassName}>{stroke.key.toUpperCase()}</kbd>);
return parts;
};
if (shortcut.type === "SEQUENCE") {
const parts: ReactNode[] = [];
shortcut.strokes.forEach((stroke) => {
parts.push(...formatStroke(stroke));
});
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
}
return (
<span className="inline-flex flex-shrink-0 items-center gap-1 text-[11px]">
{formatStroke(shortcut.stroke)}
</span>
);
}
/** Checks for conflicts given existing nodes and current path
* Throws an error if conflict is found
*
*/
function validateNoConflict(
existingNode: ShortcutTreeNode,
isLastKey: boolean,
shortcut: KeyboardShortcut,
conflictPath: string,
): void {
if (isLastKey) {
if (existingNode.type === "STEP") {
throw new ShortcutConflictError({
code: ShortcutConflictCode.ACTION_ON_SEQUENCE,
shortcut,
conflictPath,
});
} else {
throw new ShortcutConflictError({
code: ShortcutConflictCode.DUPLICATE_ACTION,
shortcut,
conflictPath,
existingShortcut: existingNode.shortcut,
});
}
} else if (existingNode.type === "ACTION") {
throw new ShortcutConflictError({
code: ShortcutConflictCode.SEQUENCE_ON_ACTION,
shortcut,
conflictPath,
existingShortcut: existingNode.shortcut,
});
}
}
/**
* Checks if the user is currently typing in an input field
*/
function isTypingInInput(event: KeyboardEvent): boolean {
if (!event.target) return false;
const target = event.target as HTMLElement;
const tagName = target.tagName.toLowerCase();
const isInput = tagName === "input" || tagName === "textarea";
const isContentEditable = target.isContentEditable;
return isInput || isContentEditable;
}
/**
* Serializes a KeyStroke to a consistent string format
* Returns lowercase string like "ctrl+shift+k" with alphabetically sorted modifiers
*/
function serializeKeyStroke(stroke: KeyStroke): string {
const key = stroke.key.toLowerCase();
if (!stroke.modifiers || stroke.modifiers.length === 0) return key;
const sorted = [...stroke.modifiers].sort();
return `${sorted.join("+")}+${key}`;
}
/**
* Converts a keyboard event to a KeyStroke
*/
function eventToKeyStroke(event: KeyboardEvent): KeyStroke {
const modifiers: ModifierKey[] = [];
if (event.altKey) modifiers.push(ModifierKey.ALT);
if (event.ctrlKey) modifiers.push(ModifierKey.CONTROL);
if (event.metaKey) modifiers.push(ModifierKey.META);
// Only include shift if the key is a letter (shift wasn't consumed to produce the character)
if (event.shiftKey && /^[a-zA-Z]$/.test(event.key))
modifiers.push(ModifierKey.SHIFT);
return { key: event.key, modifiers };
}
/**
* Serializes a keyboard event to a consistent string format
*/
function serializeEvent(event: KeyboardEvent): string {
return serializeKeyStroke(eventToKeyStroke(event));
}
/**
* Removes a path from the tree and prunes empty branches
*/
function removePathAndPrune(tree: ShortcutTreeLevel, path: string[]): void {
const [first, ...rest] = path;
if (!first) return;
if (rest.length === 0) {
delete tree[first];
return;
}
const node = tree[first];
if (!node || node.type !== "STEP") return;
removePathAndPrune(node.children, rest);
if (Object.keys(node.children).length === 0) {
delete tree[first];
}
}

View File

@@ -39,3 +39,17 @@
}
}
}
/* Tippy.js tooltip theme */
.tippy-box[data-theme~="tooltip"] {
@apply rounded-md border border-light-600 bg-white px-2 py-1 text-sm text-neutral-900 shadow-lg dark:border-dark-600 dark:bg-dark-100 dark:text-dark-1000;
}
.tippy-box[data-theme~="tooltip"] > .tippy-arrow::before {
@apply border-t-light-600 dark:border-t-dark-600;
}
.tippy-box[data-theme~="tooltip"][data-placement^="bottom"]
> .tippy-arrow::before {
@apply border-b-light-600 dark:border-b-dark-600;
}

View File

@@ -23,6 +23,8 @@ import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
import { Tooltip } from "~/components/Tooltip";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
@@ -54,6 +56,15 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
useState<PublicListId>("");
const [isInitialLoading, setIsInitialLoading] = useState(true);
const { tooltipContent: createListShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
stroke: { key: "C" },
action: () => boardId && openNewListForm(boardId),
description: t`Create new list`,
group: "ACTIONS",
});
const boardId = params?.boardId
? Array.isArray(params.boardId)
? params.boardId[0]
@@ -427,20 +438,22 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
)}
</>
)}
<Button
iconLeft={
<HiOutlinePlusSmall
className="-mr-0.5 h-5 w-5"
aria-hidden="true"
/>
}
onClick={() => {
if (boardId) openNewListForm(boardId);
}}
disabled={!boardData}
>
{t`New list`}
</Button>
<Tooltip content={createListShortcutTooltipContent}>
<Button
iconLeft={
<HiOutlinePlusSmall
className="-mr-0.5 h-5 w-5"
aria-hidden="true"
/>
}
onClick={() => {
if (boardId) openNewListForm(boardId);
}}
disabled={!boardData}
>
{t`New list`}
</Button>
</Tooltip>
<BoardDropdown
isTemplate={!!isTemplate}
isLoading={!boardData}

View File

@@ -6,6 +6,8 @@ import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { Tooltip } from "~/components/Tooltip";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { BoardsList } from "./components/BoardsList";
@@ -16,6 +18,15 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
const { openModal, modalContentType, isOpen } = useModal();
const { workspace } = useWorkspace();
const { tooltipContent: createModalShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
stroke: { key: "C" },
action: () => openModal("NEW_BOARD"),
description: t`Create new ${isTemplate ? "template" : "board"}`,
group: "ACTIONS",
});
return (
<>
<PageHead
@@ -39,16 +50,18 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
{t`Import`}
</Button>
)}
<Button
type="button"
variant="primary"
onClick={() => openModal("NEW_BOARD")}
iconLeft={
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
}
>
{t`New`}
</Button>
<Tooltip content={createModalShortcutTooltipContent}>
<Button
type="button"
variant="primary"
onClick={() => openModal("NEW_BOARD")}
iconLeft={
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
}
>
{t`New`}
</Button>
</Tooltip>
</div>
</div>