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:
@@ -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();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user