From cf2f1c96a353ab62429629dd754f80ad769b6258 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Thu, 2 Jul 2026 12:44:27 -0400 Subject: [PATCH] feat: keyboard navigation for mention and slash command dropdowns Arrow up/down cycles through items with highlight. Enter selects the highlighted item. Escape dismisses the dropdown. Index resets when the query changes. --- web/src/components/ChatArea.tsx | 60 +++++++++++++++++++++++++- web/src/components/CommandDropdown.tsx | 19 +++++--- web/src/components/MentionDropdown.tsx | 17 +++++--- 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx index 0c3e2f2..5cc0294 100644 --- a/web/src/components/ChatArea.tsx +++ b/web/src/components/ChatArea.tsx @@ -9,7 +9,7 @@ import { useMemberStore } from "../stores/member.ts"; import { useThreadStore } from "../stores/thread.ts"; import { GiphyPicker, type Gif } from "./GiphyPicker"; import { CommandDropdown } from "./CommandDropdown"; -import { findCommand } from "../lib/slashCommands"; +import { findCommand, SLASH_COMMANDS } from "../lib/slashCommands"; import { PollDisplay, CreatePollModal } from "./Poll.tsx"; import { MentionDropdown } from "./MentionDropdown"; import { useReadStatesStore } from "../stores/readStates.ts"; @@ -274,7 +274,11 @@ export function ChatArea() { const [showSearch, setShowSearch] = useState(false); const [mentionQuery, setMentionQuery] = useState(null); const [commandQuery, setCommandQuery] = useState(null); + const [dropdownIndex, setDropdownIndex] = useState(0); const [showPollModal, setShowPollModal] = useState(false); + + // Reset dropdown index when query changes + useEffect(() => { setDropdownIndex(0); }, [mentionQuery, commandQuery]); const [slowmodeRemaining, setSlowmodeRemaining] = useState(0); const [selectMode, setSelectMode] = useState(false); const [showThreads, setShowThreads] = useState(false); @@ -479,6 +483,7 @@ export function ChatArea() { const next = `${before}@${username}${separator}${after}`; setInput(next); setMentionQuery(null); + setDropdownIndex(0); // Also set the DOM value immediately so cursor positioning works inputEl.value = next; const pos = atIndex + username.length + 2; @@ -739,18 +744,69 @@ export function ChatArea() { if (e.ctrlKey && e.key === 'e') { e.preventDefault(); setShowKaomoji((prev) => !prev); + return; + } + + const isDropdownOpen = mentionQuery !== null || commandQuery !== null; + if (!isDropdownOpen) return; + + // Compute item count for the active dropdown + const itemCount = mentionQuery !== null + ? members.filter((m) => + m.username.toLowerCase().includes(mentionQuery.toLowerCase()) || + m.display_name?.toLowerCase().includes(mentionQuery.toLowerCase()) + ).slice(0, 6).length + : commandQuery !== null + ? SLASH_COMMANDS.filter((c) => c.name.startsWith(commandQuery.toLowerCase())).slice(0, 8).length + : 0; + + if (itemCount === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setDropdownIndex((prev) => (prev + 1) % itemCount); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setDropdownIndex((prev) => (prev - 1 + itemCount) % itemCount); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (mentionQuery !== null) { + const q = mentionQuery.toLowerCase(); + const filtered = members.filter((m) => + m.username.toLowerCase().includes(q) || + m.display_name?.toLowerCase().includes(q) + ).slice(0, 6); + if (filtered[dropdownIndex]) { + handleMentionSelect(filtered[dropdownIndex].username); + } + } else if (commandQuery !== null) { + const q = commandQuery.toLowerCase(); + const filtered = SLASH_COMMANDS.filter((c) => c.name.startsWith(q)).slice(0, 8); + if (filtered[dropdownIndex]) { + setInput('/' + filtered[dropdownIndex].name + ' '); + setCommandQuery(null); + setDropdownIndex(0); + inputRef.current?.focus(); + } + } + } else if (e.key === 'Escape') { + setMentionQuery(null); + setCommandQuery(null); + setDropdownIndex(0); } }} /> {mentionQuery !== null && ( - + )} {commandQuery !== null && ( { setInput('/' + name + ' '); setCommandQuery(null); + setDropdownIndex(0); inputRef.current?.focus(); }} /> diff --git a/web/src/components/CommandDropdown.tsx b/web/src/components/CommandDropdown.tsx index 8a13144..d7b55d2 100644 --- a/web/src/components/CommandDropdown.tsx +++ b/web/src/components/CommandDropdown.tsx @@ -2,10 +2,11 @@ import { SLASH_COMMANDS } from "../lib/slashCommands"; interface CommandDropdownProps { query: string; + selectedIndex: number; onSelect: (command: string) => void; } -export function CommandDropdown({ query, onSelect }: CommandDropdownProps) { +export function CommandDropdown({ query, selectedIndex, onSelect }: CommandDropdownProps) { const q = query.toLowerCase(); const filtered = SLASH_COMMANDS.filter((c) => c.name.startsWith(q)).slice(0, 8); @@ -16,7 +17,7 @@ export function CommandDropdown({ query, onSelect }: CommandDropdownProps) {
COMMANDS
- {filtered.map((cmd) => ( + {filtered.map((cmd, i) => ( ))} diff --git a/web/src/components/MentionDropdown.tsx b/web/src/components/MentionDropdown.tsx index 4dec02a..1e065d4 100644 --- a/web/src/components/MentionDropdown.tsx +++ b/web/src/components/MentionDropdown.tsx @@ -3,10 +3,11 @@ import type { Member } from "../stores/member.ts"; interface MentionDropdownProps { query: string; members: Member[]; + selectedIndex: number; onSelect: (username: string) => void; } -export function MentionDropdown({ query, members, onSelect }: MentionDropdownProps) { +export function MentionDropdown({ query, members, selectedIndex, onSelect }: MentionDropdownProps) { const q = query.toLowerCase(); const filtered = members .filter( @@ -23,7 +24,7 @@ export function MentionDropdown({ query, members, onSelect }: MentionDropdownPro
MENTION
- {filtered.map((m) => ( + {filtered.map((m, i) => ( ))}