683 lines
22 KiB
TypeScript
683 lines
22 KiB
TypeScript
import { motion, AnimatePresence } from "framer-motion";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Search,
|
|
X,
|
|
BookOpen,
|
|
Zap,
|
|
Target,
|
|
Trophy,
|
|
User,
|
|
Home,
|
|
ArrowRight,
|
|
Clock,
|
|
Flame,
|
|
} from "lucide-react";
|
|
import type { PracticeSheet } from "../types/sheet";
|
|
import { useNavigate } from "react-router-dom";
|
|
import type { SearchItem } from "../types/search";
|
|
import { formatGroupTitle } from "../lib/utils";
|
|
|
|
interface Props {
|
|
sheets: PracticeSheet[];
|
|
onClose: () => void;
|
|
searchQuery: string;
|
|
setSearchQuery: (value: string) => void;
|
|
}
|
|
|
|
// ─── Nav items ────────────────────────────────────────────────────────────────
|
|
|
|
const NAV_ITEMS: (SearchItem & {
|
|
icon: React.ElementType;
|
|
color: string;
|
|
bg: string;
|
|
})[] = [
|
|
{
|
|
type: "route",
|
|
title: "Hard Test Modules",
|
|
description: "Tackle the hardest SAT questions",
|
|
route: "/student/hard-test-modules",
|
|
group: "Pages",
|
|
icon: Trophy,
|
|
color: "#84cc16",
|
|
bg: "#f7ffe4",
|
|
},
|
|
{
|
|
type: "route",
|
|
title: "Targeted Practice",
|
|
description: "Focus on your weak spots",
|
|
route: "/student/practice/targeted-practice",
|
|
group: "Pages",
|
|
icon: Target,
|
|
color: "#ef4444",
|
|
bg: "#fff5f5",
|
|
},
|
|
{
|
|
type: "route",
|
|
title: "Drills",
|
|
description: "Train speed and accuracy",
|
|
route: "/student/practice/drills",
|
|
group: "Pages",
|
|
icon: Zap,
|
|
color: "#0891b2",
|
|
bg: "#ecfeff",
|
|
},
|
|
{
|
|
type: "route",
|
|
title: "Leaderboard",
|
|
description: "See how you rank against others",
|
|
route: "/student/rewards",
|
|
group: "Pages",
|
|
icon: Trophy,
|
|
color: "#f97316",
|
|
bg: "#fff7ed",
|
|
},
|
|
{
|
|
type: "route",
|
|
title: "Practice",
|
|
description: "Browse all practice modes",
|
|
route: "/student/practice",
|
|
group: "Pages",
|
|
icon: BookOpen,
|
|
color: "#a855f7",
|
|
bg: "#fdf4ff",
|
|
},
|
|
{
|
|
type: "route",
|
|
title: "Lessons",
|
|
description: "Watch expert SAT technique lessons",
|
|
route: "/student/lessons",
|
|
group: "Pages",
|
|
icon: BookOpen,
|
|
color: "#0891b2",
|
|
bg: "#ecfeff",
|
|
},
|
|
{
|
|
type: "route",
|
|
title: "Profile",
|
|
description: "View your profile and settings",
|
|
route: "/student/profile",
|
|
group: "Pages",
|
|
icon: User,
|
|
color: "#e11d48",
|
|
bg: "#fff1f2",
|
|
},
|
|
{
|
|
type: "route",
|
|
title: "Home",
|
|
description: "Go back to home",
|
|
route: "/student/home",
|
|
group: "Pages",
|
|
icon: Home,
|
|
color: "#f97316",
|
|
bg: "#fff7ed",
|
|
},
|
|
];
|
|
|
|
const NAV_MAP = Object.fromEntries(NAV_ITEMS.map((n) => [n.route, n]));
|
|
|
|
const STATUS_META = {
|
|
IN_PROGRESS: {
|
|
label: "In Progress",
|
|
color: "#9333ea",
|
|
bg: "#f3e8ff",
|
|
icon: "🔄",
|
|
},
|
|
COMPLETED: {
|
|
label: "Completed",
|
|
color: "#16a34a",
|
|
bg: "#f0fdf4",
|
|
icon: "✅",
|
|
},
|
|
NOT_STARTED: {
|
|
label: "Not Started",
|
|
color: "#6b7280",
|
|
bg: "#f3f4f6",
|
|
icon: "📋",
|
|
},
|
|
};
|
|
|
|
// ─── Recent items (session memory) ───────────────────────────────────────────
|
|
|
|
const SESSION_KEY = "so_recent";
|
|
const MAX_RECENT = 5;
|
|
|
|
const getRecent = (): SearchItem[] => {
|
|
try {
|
|
return JSON.parse(sessionStorage.getItem(SESSION_KEY) ?? "[]");
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
const addRecent = (item: SearchItem) => {
|
|
const prev = getRecent().filter((r) => r.route !== item.route);
|
|
const next = [item, ...prev].slice(0, MAX_RECENT);
|
|
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
|
|
};
|
|
|
|
// ─── Highlight helper ─────────────────────────────────────────────────────────
|
|
|
|
const highlightText = (text: string, query: string) => {
|
|
if (!query.trim()) return <>{text}</>;
|
|
const esc = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const regex = new RegExp(`(${esc})`, "gi");
|
|
const parts = text.split(regex);
|
|
return (
|
|
<>
|
|
{parts.map((part, i) =>
|
|
part.toLowerCase() === query.toLowerCase() ? (
|
|
<mark
|
|
key={i}
|
|
style={{
|
|
background: "#e9d5ff",
|
|
color: "#6b21a8",
|
|
borderRadius: 4,
|
|
padding: "0 2px",
|
|
}}
|
|
>
|
|
{part}
|
|
</mark>
|
|
) : (
|
|
part
|
|
),
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
|
|
|
const STYLES = `
|
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
|
|
|
.so-overlay {
|
|
position: fixed; inset: 0; z-index: 50;
|
|
background: rgba(0,0,0,0.35);
|
|
backdrop-filter: blur(8px);
|
|
-webkit-backdrop-filter: blur(8px);
|
|
display: flex; flex-direction: column;
|
|
align-items: center; padding-top: 5rem;
|
|
padding-left: 1rem; padding-right: 1rem;
|
|
}
|
|
|
|
.so-box {
|
|
width: 100%; max-width: 560px;
|
|
background: #fffbf4;
|
|
border: 2.5px solid #f3f4f6;
|
|
border-radius: 28px;
|
|
box-shadow: 0 20px 60px rgba(0,0,0,0.18), 0 6px 16px rgba(0,0,0,0.08);
|
|
overflow: hidden;
|
|
display: flex; flex-direction: column;
|
|
max-height: calc(100vh - 6rem);
|
|
}
|
|
|
|
/* Input row */
|
|
.so-input-row {
|
|
display: flex; align-items: center; gap: 0.75rem;
|
|
padding: 1rem 1.25rem;
|
|
border-bottom: 2px solid #f3f4f6;
|
|
flex-shrink: 0;
|
|
}
|
|
.so-input {
|
|
flex: 1; outline: none; border: none; background: transparent;
|
|
font-family: 'Nunito', sans-serif;
|
|
font-size: 0.95rem; font-weight: 800; color: #1e1b4b;
|
|
}
|
|
.so-input::placeholder { color: #d1d5db; font-weight: 700; }
|
|
.so-close-btn {
|
|
width: 30px; height: 30px; border-radius: 50%; border: 2.5px solid #f3f4f6;
|
|
background: white; cursor: pointer; flex-shrink: 0;
|
|
display: flex; align-items: center; justify-content: center;
|
|
transition: all 0.15s ease;
|
|
}
|
|
.so-close-btn:hover { border-color: #fecdd3; background: #fff1f2; }
|
|
|
|
/* Scrollable results */
|
|
.so-results {
|
|
overflow-y: auto; flex: 1;
|
|
padding: 0.75rem 0.75rem 1rem;
|
|
-webkit-overflow-scrolling: touch;
|
|
display: flex; flex-direction: column; gap: 1rem;
|
|
}
|
|
|
|
/* Section label */
|
|
.so-section-label {
|
|
font-size: 0.58rem; font-weight: 800; letter-spacing: 0.18em;
|
|
text-transform: uppercase; color: #9ca3af;
|
|
padding: 0 0.5rem; margin-bottom: -0.35rem;
|
|
display: flex; align-items: center; gap: 0.4rem;
|
|
}
|
|
|
|
/* Result rows */
|
|
.so-item {
|
|
display: flex; align-items: center; gap: 0.75rem;
|
|
padding: 0.7rem 0.75rem; border-radius: 16px; cursor: pointer;
|
|
transition: background 0.15s ease, transform 0.1s ease;
|
|
border: 2px solid transparent;
|
|
}
|
|
.so-item:hover {
|
|
background: white; border-color: #f3f4f6;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
|
transform: translateX(2px);
|
|
}
|
|
.so-item:active { transform: scale(0.98); }
|
|
|
|
.so-item-icon {
|
|
width: 36px; height: 36px; border-radius: 11px; flex-shrink: 0;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 0.85rem;
|
|
}
|
|
.so-item-body { flex: 1; min-width: 0; }
|
|
.so-item-title {
|
|
font-family: 'Nunito', sans-serif;
|
|
font-size: 0.88rem; font-weight: 900; color: #1e1b4b;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.so-item-desc {
|
|
font-family: 'Nunito Sans', sans-serif;
|
|
font-size: 0.72rem; font-weight: 600; color: #9ca3af;
|
|
margin-top: 0.05rem;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.so-item-arrow { color: #d1d5db; flex-shrink: 0; transition: color 0.15s ease; }
|
|
.so-item:hover .so-item-arrow { color: #a855f7; }
|
|
|
|
/* Sheet status chip inline */
|
|
.so-status-chip {
|
|
font-size: 0.6rem; font-weight: 800; letter-spacing: 0.08em;
|
|
text-transform: uppercase; border-radius: 100px; padding: 0.15rem 0.5rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* Quick nav chips (shown when empty query) */
|
|
.so-quick-wrap {
|
|
display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0 0.25rem;
|
|
}
|
|
.so-quick-chip {
|
|
display: flex; align-items: center; gap: 0.4rem;
|
|
background: white; border: 2.5px solid #f3f4f6; border-radius: 100px;
|
|
padding: 0.45rem 0.85rem; cursor: pointer;
|
|
font-family: 'Nunito', sans-serif; font-size: 0.75rem; font-weight: 800;
|
|
color: #6b7280;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
|
transition: all 0.15s ease;
|
|
}
|
|
.so-quick-chip:hover { transform: translateY(-2px); box-shadow: 0 6px 14px rgba(0,0,0,0.07); border-color: #e9d5ff; color: #a855f7; }
|
|
|
|
/* Empty state */
|
|
.so-empty {
|
|
display: flex; flex-direction: column; align-items: center;
|
|
padding: 2rem 1rem; gap: 0.5rem;
|
|
font-family: 'Nunito Sans', sans-serif;
|
|
}
|
|
.so-empty-emoji { font-size: 2rem; }
|
|
.so-empty-text { font-size: 0.85rem; font-weight: 700; color: #9ca3af; }
|
|
.so-empty-sub { font-size: 0.75rem; font-weight: 600; color: #d1d5db; text-align: center; }
|
|
|
|
/* Keyboard hint */
|
|
.so-kbd-row {
|
|
display: flex; align-items: center; justify-content: center; gap: 1rem;
|
|
padding: 0.6rem 1rem;
|
|
border-top: 2px solid #f9fafb;
|
|
flex-shrink: 0;
|
|
}
|
|
.so-kbd-hint {
|
|
display: flex; align-items: center; gap: 0.3rem;
|
|
font-family: 'Nunito Sans', sans-serif;
|
|
font-size: 0.62rem; font-weight: 600; color: #d1d5db;
|
|
}
|
|
.so-kbd {
|
|
background: white; border: 1.5px solid #e5e7eb; border-radius: 5px;
|
|
padding: 0.1rem 0.4rem; font-size: 0.6rem; font-weight: 800;
|
|
color: #9ca3af; box-shadow: 0 1px 0 #d1d5db;
|
|
}
|
|
|
|
/* Highlight count badge */
|
|
.so-count-badge {
|
|
font-family: 'Nunito', sans-serif;
|
|
font-size: 0.65rem; font-weight: 800;
|
|
background: #f3e8ff; color: #9333ea;
|
|
border-radius: 100px; padding: 0.15rem 0.5rem; flex-shrink: 0;
|
|
}
|
|
`;
|
|
|
|
// ─── Main component ───────────────────────────────────────────────────────────
|
|
|
|
export const SearchOverlay = ({
|
|
sheets,
|
|
onClose,
|
|
searchQuery,
|
|
setSearchQuery,
|
|
}: Props) => {
|
|
const navigate = useNavigate();
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [recent, setRecent] = useState<SearchItem[]>(getRecent);
|
|
const [focused, setFocused] = useState(-1); // keyboard nav index
|
|
|
|
// Build full search item list
|
|
const searchItems = useMemo<SearchItem[]>(() => {
|
|
const sheetItems = sheets.map((sheet) => ({
|
|
type: "sheet" as const,
|
|
id: sheet.id,
|
|
title: sheet.title,
|
|
description: sheet.description ?? "Practice sheet",
|
|
route: `/student/practice/${sheet.id}`,
|
|
group: formatGroupTitle(sheet.user_status),
|
|
status: sheet.user_status,
|
|
}));
|
|
return [...NAV_ITEMS, ...sheetItems];
|
|
}, [sheets]);
|
|
|
|
// Filtered + grouped results
|
|
const groupedResults = useMemo(() => {
|
|
if (!searchQuery.trim()) return {};
|
|
const q = searchQuery.toLowerCase();
|
|
const filtered = searchItems.filter(
|
|
(item) =>
|
|
item.title?.toLowerCase().includes(q) ||
|
|
item.description?.toLowerCase().includes(q),
|
|
);
|
|
return filtered.reduce<Record<string, SearchItem[]>>((acc, item) => {
|
|
(acc[item.group] ??= []).push(item);
|
|
return acc;
|
|
}, {});
|
|
}, [searchQuery, searchItems]);
|
|
|
|
const flatResults = useMemo(
|
|
() => Object.values(groupedResults).flat(),
|
|
[groupedResults],
|
|
);
|
|
|
|
// ESC to close, arrow keys + enter for keyboard nav
|
|
useEffect(() => {
|
|
const handle = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
onClose();
|
|
return;
|
|
}
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setFocused((f) => Math.min(f + 1, flatResults.length - 1));
|
|
}
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setFocused((f) => Math.max(f - 1, 0));
|
|
}
|
|
if (e.key === "Enter" && focused >= 0 && flatResults[focused]) {
|
|
handleSelect(flatResults[focused]);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handle);
|
|
return () => window.removeEventListener("keydown", handle);
|
|
}, [onClose, focused, flatResults]);
|
|
|
|
// Reset focused when query changes
|
|
useEffect(() => {
|
|
setFocused(-1);
|
|
}, [searchQuery]);
|
|
|
|
const handleSelect = (item: SearchItem) => {
|
|
addRecent(item);
|
|
setRecent(getRecent());
|
|
onClose();
|
|
navigate(item.route!);
|
|
};
|
|
|
|
const totalCount = flatResults.length;
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
className="so-overlay"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={onClose}
|
|
>
|
|
<style>{STYLES}</style>
|
|
|
|
<motion.div
|
|
className="so-box"
|
|
initial={{ y: -24, opacity: 0, scale: 0.97 }}
|
|
animate={{ y: 0, opacity: 1, scale: 1 }}
|
|
exit={{ y: -24, opacity: 0, scale: 0.97 }}
|
|
transition={{ type: "spring", stiffness: 380, damping: 28 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Input row */}
|
|
<div className="so-input-row">
|
|
<Search size={18} color="#9ca3af" />
|
|
<input
|
|
ref={inputRef}
|
|
autoFocus
|
|
className="so-input"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search sheets, pages, topics..."
|
|
/>
|
|
{searchQuery && totalCount > 0 && (
|
|
<span className="so-count-badge">
|
|
{totalCount} result{totalCount !== 1 ? "s" : ""}
|
|
</span>
|
|
)}
|
|
<button className="so-close-btn" onClick={onClose}>
|
|
<X size={13} color="#9ca3af" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Results */}
|
|
<div className="so-results">
|
|
{/* ── Empty query: recent + quick nav ── */}
|
|
{!searchQuery && (
|
|
<>
|
|
{recent.length > 0 && (
|
|
<div>
|
|
<p className="so-section-label">
|
|
<Clock size={10} /> Recent
|
|
</p>
|
|
{recent.map((item, i) => {
|
|
const navMeta = NAV_MAP[item.route!];
|
|
const Icon = navMeta?.icon ?? BookOpen;
|
|
const color = navMeta?.color ?? "#a855f7";
|
|
const bg = navMeta?.bg ?? "#fdf4ff";
|
|
return (
|
|
<div
|
|
key={i}
|
|
className="so-item"
|
|
onClick={() => handleSelect(item)}
|
|
>
|
|
<div
|
|
className="so-item-icon"
|
|
style={{ background: bg }}
|
|
>
|
|
<Icon size={16} color={color} />
|
|
</div>
|
|
<div className="so-item-body">
|
|
<p className="so-item-title">{item.title}</p>
|
|
{item.description && (
|
|
<p className="so-item-desc">{item.description}</p>
|
|
)}
|
|
</div>
|
|
<ArrowRight size={15} className="so-item-arrow" />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<p className="so-section-label">⚡ Quick nav</p>
|
|
<div
|
|
className="so-quick-wrap"
|
|
style={{ marginTop: "0.5rem" }}
|
|
>
|
|
{NAV_ITEMS.map((item, i) => (
|
|
<button
|
|
key={i}
|
|
className="so-quick-chip"
|
|
onClick={() => handleSelect(item)}
|
|
>
|
|
<item.icon size={13} color={item.color} />
|
|
{item.title}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{sheets.length > 0 && (
|
|
<div>
|
|
<p className="so-section-label">
|
|
<Flame size={10} /> In progress
|
|
</p>
|
|
{sheets
|
|
.filter((s) => s.user_status === "IN_PROGRESS")
|
|
.slice(0, 3)
|
|
.map((sheet) => {
|
|
const item: SearchItem = {
|
|
type: "sheet",
|
|
title: sheet.title,
|
|
description: sheet.description,
|
|
route: `/student/practice/${sheet.id}`,
|
|
group: "In Progress",
|
|
status: sheet.user_status,
|
|
};
|
|
return (
|
|
<div
|
|
key={sheet.id}
|
|
className="so-item"
|
|
onClick={() => handleSelect(item)}
|
|
>
|
|
<div
|
|
className="so-item-icon"
|
|
style={{ background: "#f3e8ff" }}
|
|
>
|
|
<BookOpen size={16} color="#a855f7" />
|
|
</div>
|
|
<div className="so-item-body">
|
|
<p className="so-item-title">{sheet.title}</p>
|
|
{sheet.description && (
|
|
<p className="so-item-desc">
|
|
{sheet.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<span
|
|
className="so-status-chip"
|
|
style={{
|
|
background: "#f3e8ff",
|
|
color: "#9333ea",
|
|
}}
|
|
>
|
|
In Progress
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* ── No results ── */}
|
|
{searchQuery && totalCount === 0 && (
|
|
<div className="so-empty">
|
|
<span className="so-empty-emoji">🔍</span>
|
|
<p className="so-empty-text">No results for "{searchQuery}"</p>
|
|
<p className="so-empty-sub">
|
|
Try searching for a topic, sheet title, or page name
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Results grouped ── */}
|
|
{searchQuery &&
|
|
totalCount > 0 &&
|
|
Object.entries(groupedResults).map(([group, items]) => (
|
|
<div key={group}>
|
|
<p className="so-section-label">{group}</p>
|
|
{items.map((item, index) => {
|
|
const globalIdx = flatResults.indexOf(item);
|
|
const isFocused = globalIdx === focused;
|
|
const navMeta = NAV_MAP[item.route!];
|
|
const Icon = navMeta?.icon ?? BookOpen;
|
|
const iconColor = navMeta?.color ?? "#a855f7";
|
|
const iconBg = navMeta?.bg ?? "#fdf4ff";
|
|
const statusMeta = item.status
|
|
? STATUS_META[item.status as keyof typeof STATUS_META]
|
|
: null;
|
|
|
|
return (
|
|
<motion.div
|
|
key={index}
|
|
className="so-item"
|
|
style={{
|
|
background: isFocused ? "white" : undefined,
|
|
borderColor: isFocused ? "#e9d5ff" : undefined,
|
|
boxShadow: isFocused
|
|
? "0 4px 12px rgba(0,0,0,0.06)"
|
|
: undefined,
|
|
}}
|
|
initial={{ opacity: 0, y: 6 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: index * 0.03 }}
|
|
onClick={() => handleSelect(item)}
|
|
>
|
|
<div
|
|
className="so-item-icon"
|
|
style={{ background: iconBg }}
|
|
>
|
|
{item.type === "sheet" ? (
|
|
<span style={{ fontSize: "1rem" }}>
|
|
{statusMeta?.icon ?? "📋"}
|
|
</span>
|
|
) : (
|
|
<Icon size={16} color={iconColor} />
|
|
)}
|
|
</div>
|
|
<div className="so-item-body">
|
|
<p className="so-item-title">
|
|
{highlightText(item.title, searchQuery)}
|
|
</p>
|
|
{item.description && (
|
|
<p className="so-item-desc">
|
|
{highlightText(item.description, searchQuery)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{statusMeta && (
|
|
<span
|
|
className="so-status-chip"
|
|
style={{
|
|
background: statusMeta.bg,
|
|
color: statusMeta.color,
|
|
}}
|
|
>
|
|
{statusMeta.label}
|
|
</span>
|
|
)}
|
|
<ArrowRight size={15} className="so-item-arrow" />
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Keyboard hints */}
|
|
<div className="so-kbd-row">
|
|
<div className="so-kbd-hint">
|
|
<kbd className="so-kbd">↑↓</kbd> Navigate
|
|
</div>
|
|
<div className="so-kbd-hint">
|
|
<kbd className="so-kbd">↵</kbd> Open
|
|
</div>
|
|
<div className="so-kbd-hint">
|
|
<kbd className="so-kbd">Esc</kbd> Close
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
};
|