Skip to content

Commit c2ff868

Browse files
committed
feat: split keys in shortcut combos
1 parent e373ec8 commit c2ff868

24 files changed

Lines changed: 118 additions & 156 deletions

packages/web/src/common/utils/shortcut/shortcut.util.tsx

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import {
2+
ArrowDownIcon,
3+
ArrowLeftIcon,
4+
ArrowRightIcon,
5+
ArrowUpIcon,
26
CommandIcon,
37
ControlIcon,
48
type Icon,
@@ -10,9 +14,17 @@ import {
1014
resolveModifier,
1115
} from "@tanstack/react-hotkeys";
1216

17+
// `Meta` is the platform "command" key: ⌘ on macOS, the Windows logo elsewhere.
18+
const metaIcon: Icon =
19+
detectPlatform() === "mac" ? CommandIcon : WindowsLogoIcon;
20+
1321
const keyIconMap: Record<string, Icon> = {
14-
Meta: CommandIcon,
22+
Meta: metaIcon,
1523
Control: ControlIcon,
24+
ArrowUp: ArrowUpIcon,
25+
ArrowDown: ArrowDownIcon,
26+
ArrowLeft: ArrowLeftIcon,
27+
ArrowRight: ArrowRightIcon,
1628
};
1729

1830
/** Resolves TanStack `Mod` tokens to `Meta` / `Control` for icons and labels. */
@@ -39,8 +51,10 @@ export function ShortCutLabel({ k, size = 14 }: { k: string; size?: number }) {
3951
return <IconComponent key={key} size={size} data-testid={testId} />;
4052
}
4153

54+
// Text keys inherit the surrounding font size (e.g. the keycap chip's 11px)
55+
// so letters and modifier icons read consistently together.
4256
return (
43-
<span key={key} data-testid={testId} style={{ fontSize: `${size}px` }}>
57+
<span key={key} data-testid={testId}>
4458
{key}
4559
</span>
4660
);
@@ -58,20 +72,3 @@ export const getModifierKeyLabel = (): string => {
5872

5973
export const getModifierKeyTestId = () =>
6074
`${resolveModifier("Mod").toLowerCase()}-icon`;
61-
62-
export const getModifierKeyIcon = ({ size = 14 }: { size?: number } = {}) => {
63-
const k = resolveModifier("Mod");
64-
65-
return <ShortCutLabel k={k} size={size} />;
66-
};
67-
68-
export const getMetaKeyIcon = ({ size = 14 }: { size?: number } = {}) => {
69-
const platform = detectPlatform();
70-
const testId = `${platform}-icon`;
71-
72-
if (platform === "mac") {
73-
return <CommandIcon size={size} data-testid={testId} />;
74-
}
75-
76-
return <WindowsLogoIcon size={size} data-testid={testId} />;
77-
};

packages/web/src/components/PlannerSidebar/PlannerSidebarActions/PlannerSidebarActions.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
} from "@phosphor-icons/react";
66
import { useVersionCheck } from "@web/common/hooks/useVersionCheck";
77
import { reloadLocation } from "@web/common/utils/browser/browser-navigation.util";
8-
import { getModifierKeyLabel } from "@web/common/utils/shortcut/shortcut.util";
98
import { TooltipWrapper } from "@web/components/Tooltip/TooltipWrapper";
109
import { selectIsCmdPaletteOpen } from "@web/ducks/settings/selectors/settings.selectors";
1110
import { settingsSlice } from "@web/ducks/settings/slices/settings.slice";
@@ -65,7 +64,7 @@ export const PlannerSidebarActions = ({
6564
<div className="flex items-center gap-2">
6665
<TooltipWrapper
6766
description="Open command palette"
68-
shortcut={`${getModifierKeyLabel()}+K`}
67+
shortcut="Mod+K"
6968
onClick={toggleCmdPalette}
7069
>
7170
<button

packages/web/src/components/SelectView/SelectView.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,12 @@ describe("SelectView", () => {
9696
withinDropdown
9797
.getAllByRole("option")
9898
.map((option) => option.textContent),
99-
).toEqual(["Dayd", "Weekw"]);
99+
).toEqual(["DayD", "WeekW"]);
100100

101101
const shortcutHints = withinDropdown.getAllByTestId("shortcut-hint");
102102
expect(shortcutHints).toHaveLength(2);
103-
expect(shortcutHints[0]).toHaveTextContent("d");
104-
expect(shortcutHints[1]).toHaveTextContent("w");
103+
expect(shortcutHints[0]).toHaveTextContent("D");
104+
expect(shortcutHints[1]).toHaveTextContent("W");
105105
});
106106
});
107107

@@ -258,7 +258,7 @@ describe("SelectView", () => {
258258
const shortcutHint = dayOption.querySelector(
259259
'[data-testid="shortcut-hint"]',
260260
);
261-
expect(shortcutHint).toHaveTextContent("d");
261+
expect(shortcutHint).toHaveTextContent("D");
262262
});
263263

264264
it("displays w shortcut hint for Week option", async () => {
@@ -270,7 +270,7 @@ describe("SelectView", () => {
270270
const shortcutHint = weekOption.querySelector(
271271
'[data-testid="shortcut-hint"]',
272272
);
273-
expect(shortcutHint).toHaveTextContent("w");
273+
expect(shortcutHint).toHaveTextContent("W");
274274
});
275275
});
276276

packages/web/src/components/SelectView/SelectView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { useRef, useState } from "react";
1111
import { useLocation, useNavigate } from "react-router-dom";
1212
import { ROOT_ROUTES } from "@web/common/constants/routes";
1313
import { VIEW_SHORTCUTS } from "@web/common/constants/shortcuts.constants";
14-
import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
14+
import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys";
1515

1616
interface SelectViewProps {
1717
displayLabel?: string;
@@ -179,7 +179,7 @@ export const SelectView = ({
179179
)}
180180
>
181181
<span>{option.label}</span>
182-
<ShortcutHint>{option.key}</ShortcutHint>
182+
<ShortcutKeys combo={option.key} />
183183
</div>
184184
);
185185
})}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import classNames from "classnames";
2+
import { ShortCutLabel } from "@web/common/utils/shortcut/shortcut.util";
3+
import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
4+
5+
/** Aliases for keys users might write differently than the canonical token. */
6+
const keyAliases: Record<string, string> = {
7+
cmd: "Meta",
8+
command: "Meta",
9+
ctrl: "Control",
10+
};
11+
12+
/**
13+
* Normalizes a single key token for display: uppercases lone letters
14+
* (keyboard convention, `w` -> `W`) and resolves common aliases. Named tokens
15+
* (`Shift`, `Enter`, `Mod`, `Arrow*`) pass through to `ShortCutLabel`.
16+
*/
17+
function normalizeKeyToken(key: string): string {
18+
const alias = keyAliases[key.toLowerCase()];
19+
if (alias) return alias;
20+
if (key.length === 1 && /[a-z]/i.test(key)) return key.toUpperCase();
21+
return key;
22+
}
23+
24+
interface Props {
25+
/** A `+`-joined combo, e.g. "Mod+K", "Shift+W", "Control+Meta+ArrowRight". */
26+
combo: string;
27+
title?: string;
28+
className?: string;
29+
}
30+
31+
/**
32+
* Renders a keyboard shortcut as one keycap chip per key, so multi-step combos
33+
* read as distinct keys (`[⌘] [K]`) rather than a single `"+"`-joined string.
34+
*/
35+
export function ShortcutKeys({ combo, title, className }: Props) {
36+
const keys = combo
37+
.split("+")
38+
.map((key) => key.trim())
39+
.filter(Boolean);
40+
41+
return (
42+
<span
43+
title={title}
44+
className={classNames("inline-flex items-center gap-1", className)}
45+
>
46+
{keys.map((key) => (
47+
<ShortcutHint key={key} variant="keycap">
48+
<ShortCutLabel k={normalizeKeyToken(key)} size={13} />
49+
</ShortcutHint>
50+
))}
51+
</span>
52+
);
53+
}

packages/web/src/components/Shortcuts/ShortcutList.test.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { ShortcutList } from "./ShortcutList";
33
import { describe, expect, it } from "bun:test";
44

55
describe("ShortcutList", () => {
6-
it("renders modified shortcuts in one compact keycap", () => {
6+
it("renders each key of a combo as its own keycap chip", () => {
77
render(
88
<ShortcutList
99
shortcuts={[{ k: "Shift+w", label: "Create Someday week event" }]}
@@ -16,8 +16,10 @@ describe("ShortcutList", () => {
1616
const keycaps = row.querySelectorAll("[aria-hidden='true']");
1717
const label = screen.getByText("Create Someday week event");
1818

19-
expect(keycaps).toHaveLength(1);
20-
expect(keycaps[0]?.textContent).toBe("Shift + w");
19+
// One chip per key — "Shift" and "W" — rather than a single "Shift + w".
20+
expect(keycaps).toHaveLength(2);
21+
expect(keycaps[0]?.textContent).toBe("Shift");
22+
expect(keycaps[1]?.textContent).toBe("W");
2123
expect(label).toHaveClass("flex-1");
2224
expect(label).not.toHaveClass("truncate");
2325
expect(row).toHaveClass("justify-between");

packages/web/src/components/Shortcuts/ShortcutList.tsx

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,5 @@
11
import { type Shortcut } from "@web/common/types/global.shortcut.types";
2-
import {
3-
expandModInShortcutDisplay,
4-
ShortCutLabel,
5-
} from "@web/common/utils/shortcut/shortcut.util";
6-
import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
7-
8-
function ShortcutKeySequence({ shortcutKey }: { shortcutKey: string }) {
9-
const keys = expandModInShortcutDisplay(shortcutKey).split("+");
10-
11-
return keys.map((key, idx) => {
12-
const trimmedKey = key.trim();
13-
const isLastKey = idx === keys.length - 1;
14-
15-
return (
16-
<span key={trimmedKey} className="inline-flex items-center gap-1">
17-
<ShortCutLabel k={trimmedKey} />
18-
{isLastKey ? null : (
19-
<span className="text-text-light-inactive"> + </span>
20-
)}
21-
</span>
22-
);
23-
});
24-
}
2+
import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys";
253

264
export const ShortcutList = ({ shortcuts }: { shortcuts: Shortcut[] }) => {
275
if (!shortcuts.length) return null;
@@ -34,9 +12,7 @@ export const ShortcutList = ({ shortcuts }: { shortcuts: Shortcut[] }) => {
3412
className="flex min-h-9 items-center justify-between gap-4 rounded-default py-1.5 text-[13px] text-text-lighter leading-tight"
3513
>
3614
<span className="min-w-0 flex-1 break-words">{it.label}</span>
37-
<ShortcutHint className="shrink-0 whitespace-nowrap" variant="keycap">
38-
<ShortcutKeySequence shortcutKey={it.k} />
39-
</ShortcutHint>
15+
<ShortcutKeys className="shrink-0" combo={it.k} />
4016
</li>
4117
))}
4218
</ul>

packages/web/src/components/Tooltip/TooltipWrapper.test.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,16 @@ describe("TooltipWrapper", () => {
5050
it("shows shortcut when string shortcut provided", async () => {
5151
const user = userEvent.setup();
5252
render(
53-
<TooltipWrapper shortcut="Ctrl+S">
53+
<TooltipWrapper shortcut="Shift+S">
5454
<button type="button">Save</button>
5555
</TooltipWrapper>,
5656
);
5757
const button = screen.getByRole("button", { name: /save/i });
5858
await user.hover(button);
59+
// The combo renders one chip per key — "Shift" and "S" — not "Shift+S".
5960
await waitFor(() => {
60-
expect(screen.getByText("Ctrl+S")).toBeInTheDocument();
61+
expect(screen.getByText("Shift")).toBeInTheDocument();
62+
expect(screen.getByText("S")).toBeInTheDocument();
6163
});
6264
});
6365

packages/web/src/components/Tooltip/TooltipWrapper.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type React from "react";
22
import { type ReactNode } from "react";
33
import { AlignItems, Flex } from "@web/components/Flex/Flex";
4-
import { Text } from "@web/components/Text/Text";
4+
import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys";
55
import {
66
Tooltip,
77
TooltipContent,
@@ -40,15 +40,12 @@ export const TooltipWrapper: React.FC<Props> = ({
4040
<TooltipContent>
4141
<Flex alignItems={AlignItems.CENTER}>
4242
{description && <TooltipDescription description={description} />}
43-
{shortcut && (
44-
<ShortcutHint variant="keycap">
45-
{typeof shortcut === "string" ? (
46-
<Text size="s">{shortcut}</Text>
47-
) : (
48-
shortcut
49-
)}
50-
</ShortcutHint>
51-
)}
43+
{shortcut &&
44+
(typeof shortcut === "string" ? (
45+
<ShortcutKeys combo={shortcut} />
46+
) : (
47+
<ShortcutHint variant="keycap">{shortcut}</ShortcutHint>
48+
))}
5249
</Flex>
5350
</TooltipContent>
5451
</Tooltip>

packages/web/src/views/Day/components/Shortcuts/ShortcutTip.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type ReactNode, useState } from "react";
2-
import { ShortcutHint } from "@web/components/Shortcuts/ShortcutHint";
2+
import { ShortcutKeys } from "@web/components/Shortcuts/ShortcutKeys";
33

44
interface ShortcutProps {
55
shortcut: string | string[];
@@ -13,19 +13,15 @@ const ShortcutBadge = ({
1313
}: {
1414
displayShortcut: string;
1515
ariaLabel?: string;
16-
}) => (
17-
<ShortcutHint variant="keycap" title={ariaLabel}>
18-
{displayShortcut}
19-
</ShortcutHint>
20-
);
16+
}) => <ShortcutKeys combo={displayShortcut} title={ariaLabel} />;
2117

2218
export const ShortcutTip = ({
2319
shortcut,
2420
"aria-label": ariaLabel,
2521
children,
2622
}: ShortcutProps) => {
2723
const displayShortcut = Array.isArray(shortcut)
28-
? shortcut.join(" + ")
24+
? shortcut.join("+")
2925
: shortcut;
3026
const [isHovered, setIsHovered] = useState(false);
3127

0 commit comments

Comments
 (0)