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.
This commit is contained in:
2026-07-02 12:44:27 -04:00
parent 40f8d193ff
commit cf2f1c96a3
3 changed files with 84 additions and 12 deletions
+58 -2
View File
@@ -9,7 +9,7 @@ import { useMemberStore } from "../stores/member.ts";
import { useThreadStore } from "../stores/thread.ts"; import { useThreadStore } from "../stores/thread.ts";
import { GiphyPicker, type Gif } from "./GiphyPicker"; import { GiphyPicker, type Gif } from "./GiphyPicker";
import { CommandDropdown } from "./CommandDropdown"; import { CommandDropdown } from "./CommandDropdown";
import { findCommand } from "../lib/slashCommands"; import { findCommand, SLASH_COMMANDS } from "../lib/slashCommands";
import { PollDisplay, CreatePollModal } from "./Poll.tsx"; import { PollDisplay, CreatePollModal } from "./Poll.tsx";
import { MentionDropdown } from "./MentionDropdown"; import { MentionDropdown } from "./MentionDropdown";
import { useReadStatesStore } from "../stores/readStates.ts"; import { useReadStatesStore } from "../stores/readStates.ts";
@@ -274,7 +274,11 @@ export function ChatArea() {
const [showSearch, setShowSearch] = useState(false); const [showSearch, setShowSearch] = useState(false);
const [mentionQuery, setMentionQuery] = useState<string | null>(null); const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [commandQuery, setCommandQuery] = useState<string | null>(null); const [commandQuery, setCommandQuery] = useState<string | null>(null);
const [dropdownIndex, setDropdownIndex] = useState(0);
const [showPollModal, setShowPollModal] = useState(false); const [showPollModal, setShowPollModal] = useState(false);
// Reset dropdown index when query changes
useEffect(() => { setDropdownIndex(0); }, [mentionQuery, commandQuery]);
const [slowmodeRemaining, setSlowmodeRemaining] = useState(0); const [slowmodeRemaining, setSlowmodeRemaining] = useState(0);
const [selectMode, setSelectMode] = useState(false); const [selectMode, setSelectMode] = useState(false);
const [showThreads, setShowThreads] = useState(false); const [showThreads, setShowThreads] = useState(false);
@@ -479,6 +483,7 @@ export function ChatArea() {
const next = `${before}@${username}${separator}${after}`; const next = `${before}@${username}${separator}${after}`;
setInput(next); setInput(next);
setMentionQuery(null); setMentionQuery(null);
setDropdownIndex(0);
// Also set the DOM value immediately so cursor positioning works // Also set the DOM value immediately so cursor positioning works
inputEl.value = next; inputEl.value = next;
const pos = atIndex + username.length + 2; const pos = atIndex + username.length + 2;
@@ -739,18 +744,69 @@ export function ChatArea() {
if (e.ctrlKey && e.key === 'e') { if (e.ctrlKey && e.key === 'e') {
e.preventDefault(); e.preventDefault();
setShowKaomoji((prev) => !prev); 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 && ( {mentionQuery !== null && (
<MentionDropdown query={mentionQuery} members={members} onSelect={handleMentionSelect} /> <MentionDropdown query={mentionQuery} members={members} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
)} )}
{commandQuery !== null && ( {commandQuery !== null && (
<CommandDropdown <CommandDropdown
query={commandQuery} query={commandQuery}
selectedIndex={dropdownIndex}
onSelect={(name) => { onSelect={(name) => {
setInput('/' + name + ' '); setInput('/' + name + ' ');
setCommandQuery(null); setCommandQuery(null);
setDropdownIndex(0);
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
/> />
+14 -5
View File
@@ -2,10 +2,11 @@ import { SLASH_COMMANDS } from "../lib/slashCommands";
interface CommandDropdownProps { interface CommandDropdownProps {
query: string; query: string;
selectedIndex: number;
onSelect: (command: string) => void; onSelect: (command: string) => void;
} }
export function CommandDropdown({ query, onSelect }: CommandDropdownProps) { export function CommandDropdown({ query, selectedIndex, onSelect }: CommandDropdownProps) {
const q = query.toLowerCase(); const q = query.toLowerCase();
const filtered = SLASH_COMMANDS.filter((c) => c.name.startsWith(q)).slice(0, 8); const filtered = SLASH_COMMANDS.filter((c) => c.name.startsWith(q)).slice(0, 8);
@@ -16,7 +17,7 @@ export function CommandDropdown({ query, onSelect }: CommandDropdownProps) {
<div className="px-2 py-1 text-xs text-gb-fg-s font-mono border-b border-gb-bg-t"> <div className="px-2 py-1 text-xs text-gb-fg-s font-mono border-b border-gb-bg-t">
COMMANDS COMMANDS
</div> </div>
{filtered.map((cmd) => ( {filtered.map((cmd, i) => (
<button <button
key={cmd.name} key={cmd.name}
type="button" type="button"
@@ -24,10 +25,18 @@ export function CommandDropdown({ query, onSelect }: CommandDropdownProps) {
e.preventDefault(); e.preventDefault();
onSelect(cmd.name); onSelect(cmd.name);
}} }}
className="w-full text-left px-2 py-1 text-sm font-mono hover:bg-gb-bg-t text-gb-fg flex items-center gap-2" className={`w-full text-left px-2 py-1 text-sm font-mono flex items-center gap-2 transition-colors ${
i === selectedIndex
? "bg-gb-orange text-gb-bg"
: "hover:bg-gb-bg-t text-gb-fg"
}`}
> >
<span className="text-gb-orange">/{cmd.name}</span> <span className={i === selectedIndex ? "text-gb-bg" : "text-gb-orange"}>
<span className="text-gb-fg-f text-xs truncate">{cmd.description}</span> /{cmd.name}
</span>
<span className={`text-xs truncate ${i === selectedIndex ? "text-gb-bg" : "text-gb-fg-f"}`}>
{cmd.description}
</span>
</button> </button>
))} ))}
</div> </div>
+12 -5
View File
@@ -3,10 +3,11 @@ import type { Member } from "../stores/member.ts";
interface MentionDropdownProps { interface MentionDropdownProps {
query: string; query: string;
members: Member[]; members: Member[];
selectedIndex: number;
onSelect: (username: string) => void; onSelect: (username: string) => void;
} }
export function MentionDropdown({ query, members, onSelect }: MentionDropdownProps) { export function MentionDropdown({ query, members, selectedIndex, onSelect }: MentionDropdownProps) {
const q = query.toLowerCase(); const q = query.toLowerCase();
const filtered = members const filtered = members
.filter( .filter(
@@ -23,7 +24,7 @@ export function MentionDropdown({ query, members, onSelect }: MentionDropdownPro
<div className="px-2 py-1 text-xs text-gb-fg-s font-mono border-b border-gb-bg-t"> <div className="px-2 py-1 text-xs text-gb-fg-s font-mono border-b border-gb-bg-t">
MENTION MENTION
</div> </div>
{filtered.map((m) => ( {filtered.map((m, i) => (
<button <button
key={m.id} key={m.id}
type="button" type="button"
@@ -31,12 +32,18 @@ export function MentionDropdown({ query, members, onSelect }: MentionDropdownPro
e.preventDefault(); e.preventDefault();
onSelect(m.username); onSelect(m.username);
}} }}
className="w-full px-2 py-1 text-left text-sm font-mono flex items-center gap-2 hover:bg-gb-orange hover:text-gb-bg transition-colors" className={`w-full px-2 py-1 text-left text-sm font-mono flex items-center gap-2 transition-colors ${
i === selectedIndex
? "bg-gb-orange text-gb-bg"
: "hover:bg-gb-orange hover:text-gb-bg"
}`}
> >
<span className="text-gb-green"></span> <span className={i === selectedIndex ? "text-gb-bg" : "text-gb-green"}></span>
<span className="truncate">{m.display_name || m.username}</span> <span className="truncate">{m.display_name || m.username}</span>
{m.display_name && m.display_name !== m.username && ( {m.display_name && m.display_name !== m.username && (
<span className="text-gb-fg-f text-xs">({m.username})</span> <span className={`text-xs ${i === selectedIndex ? "text-gb-bg" : "text-gb-fg-f"}`}>
({m.username})
</span>
)} )}
</button> </button>
))} ))}