Skip to content

Commit b51b37f

Browse files
koki-developclaude
andcommitted
feat(ui): alias Ctrl+N / Ctrl+P to ArrowDown / ArrowUp in list navigation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent db27ab5 commit b51b37f

6 files changed

Lines changed: 94 additions & 51 deletions

File tree

src/components/molecules/MenuList.tsx

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { clsx } from "clsx";
22
import { useEffect, useRef } from "react";
33

4+
import { isArrowDownKey, isArrowUpKey } from "@/lib/keyboard";
5+
46
import type { DropdownMenuItem } from "./DropdownMenu";
57

68
export type MenuListProps = {
@@ -14,7 +16,7 @@ const itemColorStyles: Record<NonNullable<DropdownMenuItem["color"]>, string> =
1416
"text-red-400 hover:bg-red-500/10 hover:text-red-300 focus:bg-red-500/10 focus:text-red-300",
1517
};
1618

17-
const NAV_KEYS = new Set(["Tab", "ArrowDown", "ArrowUp", "Home", "End"]);
19+
const STATIC_NAV_KEYS = new Set(["Tab", "Home", "End"]);
1820

1921
/**
2022
* Renders a focus-managed menu list shared by `DropdownMenu` and `ContextMenu`.
@@ -60,7 +62,9 @@ export function MenuList({ items, onSelect }: MenuListProps) {
6062

6163
const handleKeyDown = (e: KeyboardEvent) => {
6264
if (e.defaultPrevented) return;
63-
if (!NAV_KEYS.has(e.key)) return;
65+
const downKey = isArrowDownKey(e);
66+
const upKey = isArrowUpKey(e);
67+
if (!downKey && !upKey && !STATIC_NAV_KEYS.has(e.key)) return;
6468
if (!container) return;
6569
const buttons = Array.from(container.querySelectorAll<HTMLButtonElement>("button"));
6670
if (buttons.length === 0) return;
@@ -71,33 +75,33 @@ export function MenuList({ items, onSelect }: MenuListProps) {
7175
const currentIndex = focusedButton ? buttons.indexOf(focusedButton) : -1;
7276

7377
let nextIndex: number;
74-
switch (e.key) {
75-
case "Tab":
76-
if (currentIndex < 0) {
77-
nextIndex = e.shiftKey ? buttons.length - 1 : 0;
78-
} else {
79-
nextIndex = e.shiftKey
80-
? (currentIndex - 1 + buttons.length) % buttons.length
81-
: (currentIndex + 1) % buttons.length;
82-
}
83-
break;
84-
case "ArrowDown":
85-
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % buttons.length;
86-
break;
87-
case "ArrowUp":
88-
nextIndex =
89-
currentIndex < 0
90-
? buttons.length - 1
91-
: (currentIndex - 1 + buttons.length) % buttons.length;
92-
break;
93-
case "Home":
94-
nextIndex = 0;
95-
break;
96-
case "End":
97-
nextIndex = buttons.length - 1;
98-
break;
99-
default:
100-
return;
78+
if (downKey) {
79+
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % buttons.length;
80+
} else if (upKey) {
81+
nextIndex =
82+
currentIndex < 0
83+
? buttons.length - 1
84+
: (currentIndex - 1 + buttons.length) % buttons.length;
85+
} else {
86+
switch (e.key) {
87+
case "Tab":
88+
if (currentIndex < 0) {
89+
nextIndex = e.shiftKey ? buttons.length - 1 : 0;
90+
} else {
91+
nextIndex = e.shiftKey
92+
? (currentIndex - 1 + buttons.length) % buttons.length
93+
: (currentIndex + 1) % buttons.length;
94+
}
95+
break;
96+
case "Home":
97+
nextIndex = 0;
98+
break;
99+
case "End":
100+
nextIndex = buttons.length - 1;
101+
break;
102+
default:
103+
return;
104+
}
101105
}
102106
e.preventDefault();
103107
buttons[nextIndex].focus();

src/components/molecules/Select.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { createPortal } from "react-dom";
66
import { useAnchorRect } from "@/hooks/ui/useAnchorRect";
77
import { useClickOutside } from "@/hooks/ui/useClickOutside";
88
import { useEscapeKey } from "@/hooks/ui/useEscapeKey";
9+
import { isArrowDownKey, isArrowUpKey } from "@/lib/keyboard";
910

1011
export type SelectOption = {
1112
label: string;
@@ -32,15 +33,17 @@ export function Select({ value, onChange, options }: SelectProps) {
3233

3334
const handleKeyDown = useEffectEvent((e: KeyboardEvent) => {
3435
if (options.length === 0) return;
36+
if (isArrowDownKey(e)) {
37+
e.preventDefault();
38+
setHighlightedIndex((prev) => (prev + 1) % options.length);
39+
return;
40+
}
41+
if (isArrowUpKey(e)) {
42+
e.preventDefault();
43+
setHighlightedIndex((prev) => (prev - 1 + options.length) % options.length);
44+
return;
45+
}
3546
switch (e.key) {
36-
case "ArrowDown":
37-
e.preventDefault();
38-
setHighlightedIndex((prev) => (prev + 1) % options.length);
39-
break;
40-
case "ArrowUp":
41-
e.preventDefault();
42-
setHighlightedIndex((prev) => (prev - 1 + options.length) % options.length);
43-
break;
4447
case "Enter":
4548
case " ":
4649
e.preventDefault();

src/components/molecules/TagEditor.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
import { TagChip } from "@/components/atoms";
1313
import { useAnchorRect } from "@/hooks/ui/useAnchorRect";
1414
import { useClickOutside } from "@/hooks/ui/useClickOutside";
15-
import { isImeKeyEvent } from "@/lib/keyboard";
15+
import { isArrowDownKey, isArrowUpKey, isImeKeyEvent } from "@/lib/keyboard";
1616
import { commitPending, fuzzySubsequenceMatch } from "@/lib/tags";
1717

1818
import { TagSuggestionPopover } from "./TagSuggestionPopover";
@@ -181,12 +181,12 @@ export function TagEditor({
181181
// (e.g. zero matches), Escape must fall through to close the enclosing
182182
// dialog instead of being consumed as a popover dismiss.
183183
if (!popoverVisible) return false;
184-
if (e.key === "ArrowDown") {
184+
if (isArrowDownKey(e)) {
185185
e.preventDefault();
186186
setSelectedIndex((i) => (i < 0 ? 0 : (i + 1) % filteredSuggestions.length));
187187
return true;
188188
}
189-
if (e.key === "ArrowUp") {
189+
if (isArrowUpKey(e)) {
190190
e.preventDefault();
191191
setSelectedIndex((i) =>
192192
i < 0

src/components/organisms/settings/McpSetupSnippets.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type KeyboardEvent, useId, useRef, useState } from "react";
33

44
import { Text } from "@/components/atoms";
55
import { CodeBlock } from "@/components/molecules";
6+
import { isArrowDownKey, isArrowUpKey } from "@/lib/keyboard";
67
import type { McpSetupSnippet } from "@/types";
78

89
export type McpSetupSnippetsProps = {
@@ -35,17 +36,17 @@ export function McpSetupSnippets({ snippets }: McpSetupSnippetsProps) {
3536

3637
const handleKeyDown = (e: KeyboardEvent) => {
3738
const last = snippets.length - 1;
39+
if (e.key === "ArrowRight" || isArrowDownKey(e)) {
40+
e.preventDefault();
41+
focusTab(active === last ? 0 : active + 1);
42+
return;
43+
}
44+
if (e.key === "ArrowLeft" || isArrowUpKey(e)) {
45+
e.preventDefault();
46+
focusTab(active === 0 ? last : active - 1);
47+
return;
48+
}
3849
switch (e.key) {
39-
case "ArrowRight":
40-
case "ArrowDown":
41-
e.preventDefault();
42-
focusTab(active === last ? 0 : active + 1);
43-
break;
44-
case "ArrowLeft":
45-
case "ArrowUp":
46-
e.preventDefault();
47-
focusTab(active === 0 ? last : active - 1);
48-
break;
4950
case "Home":
5051
e.preventDefault();
5152
focusTab(0);

src/lib/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Domain helpers that take inputs and return values. **No React, no Tauri, no `@/a
66

77
- `board.ts` — Kanban column state derivation. `groupTasksByStatus`, `moveTaskToIndex` (geometry-aware insertion for column empty-area drops), `calculateMidpoint` + `computeDropOrder` (the floating-point `order` slot picker that triggers a backend `renumber` when precision is exhausted), and the `UNKNOWN_STATUS` sentinel for tasks whose status no longer matches any column.
88
- `filter.ts``isValidFilter`: trims operand-less tag filters out of visible-count / save payloads.
9-
- `keyboard.ts``isImeKeyEvent`: returns true when a keydown was generated by an IME composition step (composition, confirm, cancel). Handles both native `KeyboardEvent` and React's synthetic, including the WebKit quirk where `compositionend` fires before the confirming/cancelling keydown.
9+
- `keyboard.ts``isImeKeyEvent`: returns true when a keydown was generated by an IME composition step (composition, confirm, cancel). Handles both native `KeyboardEvent` and React's synthetic, including the WebKit quirk where `compositionend` fires before the confirming/cancelling keydown. `isArrowDownKey` / `isArrowUpKey`: project-wide directional-key detectors — match either the literal `ArrowDown` / `ArrowUp` key or the Emacs-style `Ctrl+N` / `Ctrl+P`. Every list / menu / popover / tab navigation handler routes through these helpers so the two stay perfectly aligned. Ctrl+N/P is matched only with the plain Control modifier so it never collides with chords like `Cmd+N` ("New Task") or `Ctrl+Shift+N` ("New Window").
1010
- `statuses.ts` — Status edit support: `labelKey` (cheap equality), `buildCandidateStatuses` (trim + drop blanks), `hasDuplicateLabel`, `statusEntriesEqual`, `buildRenameMap` (drives backend frontmatter migration on rename).
1111
- `tags.ts``commitPending` (dedup + trim a pending tag input), `fuzzySubsequenceMatch` (autocomplete predicate), `fuzzySubsequenceMatchIndices` (the matched-character indices used to highlight suggestions).
1212
- `task.ts` — Task form diff: `TaskFormSnapshot`, `computeDirtyUpdates` (returns a sparse `TaskUpdates`), `withTaskUpdates`.

src/lib/keyboard.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,38 @@ export const isImeKeyEvent = (e: ImeAwareKeyEvent): boolean => {
2222
const composing = e.nativeEvent?.isComposing ?? e.isComposing ?? false;
2323
return composing || e.keyCode === 229;
2424
};
25+
26+
type DirectionalKeyEvent = {
27+
key: string;
28+
ctrlKey: boolean;
29+
metaKey: boolean;
30+
altKey: boolean;
31+
shiftKey: boolean;
32+
};
33+
34+
// Plain Ctrl with no co-modifier. Ctrl+Shift+N is "New Window" and Cmd+N is
35+
// "New Task" — neither must alias to a navigation key, so we reject any chord
36+
// that combines Ctrl with another modifier.
37+
const isPlainCtrl = (e: DirectionalKeyEvent): boolean =>
38+
e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
39+
40+
/**
41+
* True when the keydown represents "move down" — either the literal ArrowDown
42+
* key or the Emacs-style Ctrl+N. Every list / menu / popover / tab navigation
43+
* handler in the project routes through this helper so Ctrl+N stays universally
44+
* aligned with ArrowDown.
45+
*
46+
* Matched only with the *plain* Control modifier (no Cmd / Alt / Shift), so the
47+
* shortcut never collides with chords that already mean something else
48+
* (Cmd+N "New Task", Ctrl+Shift+N "New Window").
49+
*/
50+
export const isArrowDownKey = (e: DirectionalKeyEvent): boolean =>
51+
e.key === "ArrowDown" || (isPlainCtrl(e) && e.key.toLowerCase() === "n");
52+
53+
/**
54+
* True when the keydown represents "move up" — either the literal ArrowUp key
55+
* or the Emacs-style Ctrl+P. Pairs with `isArrowDownKey` for keyboard
56+
* navigation across the project.
57+
*/
58+
export const isArrowUpKey = (e: DirectionalKeyEvent): boolean =>
59+
e.key === "ArrowUp" || (isPlainCtrl(e) && e.key.toLowerCase() === "p");

0 commit comments

Comments
 (0)