mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-08-22 08:34:13 -05:00
Refactor tag selection in SubmitForm
This commit is contained in:
@@ -7,29 +7,10 @@ import { baseRoms } from "@/data/baseRoms";
|
||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from "@headlessui/react";
|
||||
import { useFloating, offset, flip, shift, size, autoUpdate } from "@floating-ui/react";
|
||||
import { IconType } from "react-icons";
|
||||
import {
|
||||
MdCatchingPokemon,
|
||||
MdNewReleases,
|
||||
MdAutoFixHigh,
|
||||
MdSettingsSuggest,
|
||||
} from "react-icons/md";
|
||||
import { MdTune } from "react-icons/md";
|
||||
import { BiSolidGame } from "react-icons/bi";
|
||||
import { FaClock, FaGaugeHigh, FaMasksTheater } from "react-icons/fa6";
|
||||
import { BsSdCardFill } from "react-icons/bs";
|
||||
import { IoLogoGameControllerA } from "react-icons/io";
|
||||
import { CATEGORY_ICONS } from "@/components/Icons/tagCategories";
|
||||
|
||||
const CATEGORY_ICON: Record<string, IconType> = {
|
||||
"Pokédex": MdCatchingPokemon,
|
||||
"Sprites": BiSolidGame,
|
||||
"New": MdNewReleases,
|
||||
"Altered": MdAutoFixHigh,
|
||||
"Quality of Life": MdSettingsSuggest,
|
||||
"Gameplay": IoLogoGameControllerA,
|
||||
"Difficulty": FaGaugeHigh,
|
||||
"Scale": FaClock,
|
||||
"Tone": FaMasksTheater,
|
||||
};
|
||||
|
||||
export default function DiscoverBrowser() {
|
||||
const supabase = createClient();
|
||||
@@ -220,7 +201,7 @@ export default function DiscoverBrowser() {
|
||||
.map((cat) => (
|
||||
<MultiSelectDropdown
|
||||
key={cat}
|
||||
icon={CATEGORY_ICON[cat]}
|
||||
icon={CATEGORY_ICONS[cat]}
|
||||
label={cat}
|
||||
options={tagGroups[cat].map((t) => ({ id: t, name: t }))}
|
||||
values={selectedTags.filter((t) => tagGroups[cat].includes(t))}
|
||||
|
||||
24
src/components/Icons/tagCategories.ts
Normal file
24
src/components/Icons/tagCategories.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IconType } from "react-icons";
|
||||
import { MdCatchingPokemon, MdNewReleases, MdAutoFixHigh, MdSettingsSuggest } from "react-icons/md";
|
||||
import { BiSolidGame } from "react-icons/bi";
|
||||
import { FaClock, FaGaugeHigh, FaMasksTheater } from "react-icons/fa6";
|
||||
import { IoLogoGameControllerA } from "react-icons/io";
|
||||
|
||||
export const CATEGORY_ICONS: Record<string, IconType> = {
|
||||
"Pokédex": MdCatchingPokemon,
|
||||
"Sprites": BiSolidGame,
|
||||
"New": MdNewReleases,
|
||||
"Altered": MdAutoFixHigh,
|
||||
"Quality of Life": MdSettingsSuggest,
|
||||
"Gameplay": IoLogoGameControllerA,
|
||||
"Difficulty": FaGaugeHigh,
|
||||
"Scale": FaClock,
|
||||
"Tone": FaMasksTheater,
|
||||
};
|
||||
|
||||
export function getCategoryIcon(category: string | null | undefined): IconType | undefined {
|
||||
if (!category) return undefined;
|
||||
return CATEGORY_ICONS[category];
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { RxDragHandleDots2, RxPlus } from "react-icons/rx";
|
||||
import { useAuthContext } from "@/contexts/AuthContext";
|
||||
import { Database } from "@/types/db";
|
||||
import { useBaseRoms } from "@/contexts/BaseRomContext";
|
||||
import TagSelector from "@/components/Submit/TagSelector";
|
||||
import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js";
|
||||
import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js";
|
||||
import { sha1Hex } from "@/utils/hash";
|
||||
@@ -59,9 +60,7 @@ interface SubmitFormProps {
|
||||
dummy?: boolean;
|
||||
}
|
||||
|
||||
type TagSortable = Database["public"]["Tables"]["tags"]["Row"] & {
|
||||
popularity: number;
|
||||
};
|
||||
// Tag selection handled by TagSelector component
|
||||
|
||||
export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
const MAX_COVERS = 10;
|
||||
@@ -82,7 +81,6 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
const [twitter, setTwitter] = React.useState("");
|
||||
const [pokecommunity, setPokecommunity] = React.useState("");
|
||||
const [tags, setTags] = React.useState<string[]>([]);
|
||||
const [tagsInput, setTagsInput] = React.useState("");
|
||||
const [showMdPreview, setShowMdPreview] = React.useState(false);
|
||||
const [patchFile, setPatchFile] = React.useState<File | null>(null);
|
||||
const [patchMode, setPatchMode] = React.useState<"bps" | "rom">("bps");
|
||||
@@ -185,53 +183,9 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
setNewCoverFiles((prev) => arrayMove(prev, oldIndex, newIndex));
|
||||
};
|
||||
|
||||
// Existing tags (from DB)
|
||||
const [allTags, setAllTags] = React.useState<TagSortable[]>([]);
|
||||
const [isTagDropdownOpen, setIsTagDropdownOpen] = React.useState(false);
|
||||
const tagAreaRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const tagsInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [activeTagIndex, setActiveTagIndex] = React.useState(0);
|
||||
// Tags handled in TagSelector
|
||||
|
||||
React.useEffect(() => {
|
||||
// Fetch all tags once on mount
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase.from('tags').select('id, name, usage: hack_tags (count)');
|
||||
if (error) return;
|
||||
const fetchedTags: TagSortable[] = (data || []).map((t: any) => ({ id: t.id, name: t.name, popularity: t.usage[0].count || 0, category: t.category }));
|
||||
setAllTags(
|
||||
fetchedTags.sort((a, b) => {
|
||||
if (b.popularity !== a.popularity) {
|
||||
return b.popularity - a.popularity;
|
||||
}
|
||||
// If popularity is equal, sort by id
|
||||
if (a.id < b.id) return -1;
|
||||
if (a.id > b.id) return 1;
|
||||
return 0;
|
||||
})
|
||||
);
|
||||
} catch {}
|
||||
};
|
||||
fetchTags();
|
||||
}, [supabase]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (!tagAreaRef.current) return;
|
||||
if (!tagAreaRef.current.contains(e.target as Node)) {
|
||||
setIsTagDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, []);
|
||||
|
||||
const filteredTags = React.useMemo(() => {
|
||||
const q = tagsInput.trim().toLowerCase();
|
||||
const pool = allTags.filter((t) => !tags.includes(t.name));
|
||||
if (!q) return pool.slice(0, 15);
|
||||
return pool.filter((t) => t.name.toLowerCase().includes(q)).slice(0, 8);
|
||||
}, [allTags, tags, tagsInput]);
|
||||
// Tag fetching and filtering moved to TagSelector
|
||||
|
||||
// Focus the first input on each step when step changes
|
||||
React.useEffect(() => {
|
||||
@@ -247,54 +201,7 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
}
|
||||
}, [step, isDummy]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTagDropdownOpen) return;
|
||||
if (filteredTags.length === 0) {
|
||||
setActiveTagIndex(0);
|
||||
} else {
|
||||
setActiveTagIndex((i) => Math.min(Math.max(0, i), filteredTags.length - 1));
|
||||
}
|
||||
}, [filteredTags, isTagDropdownOpen]);
|
||||
|
||||
const addTag = (value: string) => {
|
||||
const tag = value.trim();
|
||||
if (!tag) return;
|
||||
if (!allTags.some((t) => t.name === tag)) return; // only allow existing tags
|
||||
if (tags.includes(tag)) return;
|
||||
setTags((prev) => [...prev, tag]);
|
||||
setTagsInput("");
|
||||
};
|
||||
|
||||
const removeTagAt = (index: number) => {
|
||||
setTags((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const onTagsKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (!isTagDropdownOpen) setIsTagDropdownOpen(true);
|
||||
if (filteredTags.length > 0) setActiveTagIndex((i) => (i + 1) % filteredTags.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (!isTagDropdownOpen) setIsTagDropdownOpen(true);
|
||||
if (filteredTags.length > 0) setActiveTagIndex((i) => (i - 1 + filteredTags.length) % filteredTags.length);
|
||||
} else if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
const choice = filteredTags[activeTagIndex] || filteredTags[0];
|
||||
if (choice) {
|
||||
addTag(choice.name);
|
||||
setIsTagDropdownOpen(true);
|
||||
}
|
||||
} else if (e.key === 'Tab') {
|
||||
// Close dropdown and allow tabbing to next focusable element
|
||||
setIsTagDropdownOpen(false);
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsTagDropdownOpen(false);
|
||||
} else if (e.key === 'Backspace' && !tagsInput && tags.length > 0) {
|
||||
e.preventDefault();
|
||||
setTags((prev) => prev.slice(0, prev.length - 1));
|
||||
}
|
||||
};
|
||||
// Removed legacy key handling; TagSelector manages interactions
|
||||
|
||||
const slugify = (text: string) =>
|
||||
text
|
||||
@@ -566,53 +473,7 @@ export default function SubmitForm({ dummy = false }: SubmitFormProps) {
|
||||
{/* Tags */}
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-foreground/80">Tags <span className="text-red-500">*</span></label>
|
||||
<div ref={tagAreaRef} className="rounded-md ring-1 ring-inset ring-[var(--border)] bg-[var(--surface-2)] px-2 py-2 relative">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((t, i) => (
|
||||
<span key={`${t}-${i}`} className="inline-flex items-center gap-1 rounded-full bg-[var(--surface-2)] px-2 py-1 text-xs ring-1 ring-[var(--border)]">
|
||||
{t}
|
||||
{!isDummy && (
|
||||
<button type="button" onClick={() => removeTagAt(i)} className="ml-1 text-foreground/70 hover:text-foreground">×</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{!isDummy ? (
|
||||
<input
|
||||
ref={tagsInputRef}
|
||||
value={tagsInput}
|
||||
onChange={(e) => { setTagsInput(e.target.value); setIsTagDropdownOpen(true); }}
|
||||
onKeyDown={onTagsKeyDown}
|
||||
onFocus={() => setIsTagDropdownOpen(true)}
|
||||
placeholder={tags.length ? "Add tag" : "Add tags (e.g. QoL, Challenge)"}
|
||||
className={`flex-1 min-w-[8rem] bg-transparent px-2 text-sm placeholder:text-foreground/50 focus:outline-none`}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 min-w-[8rem] px-2 text-sm text-foreground/50 select-none">{tags.length ? "Add tag" : "Add tags (e.g. QoL, Challenge)"}</div>
|
||||
)}
|
||||
</div>
|
||||
{!isDummy && isTagDropdownOpen && filteredTags.length > 0 && (
|
||||
<div className="absolute left-0 right-0 z-20 mt-2 max-h-64 overflow-auto rounded-md border border-[var(--border)] bg-[var(--surface-1)] backdrop-blur-xl p-1 shadow-xl">
|
||||
<div className="grid">
|
||||
{filteredTags.map((t, idx) => {
|
||||
const isActive = idx === activeTagIndex;
|
||||
return (
|
||||
<button
|
||||
key={t.name}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => setActiveTagIndex(idx)}
|
||||
onClick={() => {addTag(t.name); tagsInputRef.current?.focus();}}
|
||||
className={`flex items-center justify-between gap-2 rounded px-2 py-2 text-left text-sm transition-colors ${isActive ? 'bg-black/5 dark:bg-white/10' : 'hover:bg-black/5 dark:hover:bg-white/10'}`}
|
||||
>
|
||||
<span className="truncate">{t.name}</span>
|
||||
<RxPlus className="shrink-0 opacity-80" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TagSelector value={tags} onChange={setTags} />
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
|
||||
315
src/components/Submit/TagSelector.tsx
Normal file
315
src/components/Submit/TagSelector.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
import { MdTune } from "react-icons/md";
|
||||
import { CATEGORY_ICONS, getCategoryIcon } from "@/components/Icons/tagCategories";
|
||||
import { FaTimes } from "react-icons/fa";
|
||||
|
||||
type TagRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string | null;
|
||||
popularity: number;
|
||||
};
|
||||
|
||||
export interface TagSelectorProps {
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}
|
||||
|
||||
export default function TagSelector({ value, onChange }: TagSelectorProps) {
|
||||
const supabase = createClient();
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [allTags, setAllTags] = React.useState<TagRow[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [activeCategory, _setActiveCategory] = React.useState<string | "advanced" | null>(null);
|
||||
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const categoryRefs = React.useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const categoriesContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const tagsContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const tagItemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [activeTagIndex, setActiveTagIndex] = React.useState<number | null>(null);
|
||||
const [categoriesPaneFocused, setCategoriesPaneFocused] = React.useState(false);
|
||||
|
||||
const setActiveCategory = React.useCallback((cat: string | "advanced" | null) => {
|
||||
_setActiveCategory(cat);
|
||||
setActiveTagIndex(null);
|
||||
setCategoriesPaneFocused(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data } = await supabase
|
||||
.from("tags")
|
||||
.select("id,name,category,usage: hack_tags (count)");
|
||||
const rows: TagRow[] = (data || []).map((t: any) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
category: t.category ?? null,
|
||||
popularity: t.usage?.[0]?.count || 0,
|
||||
}));
|
||||
rows.sort((a, b) => (b.popularity - a.popularity) || a.name.localeCompare(b.name));
|
||||
setAllTags(rows);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [supabase]);
|
||||
|
||||
const grouped = React.useMemo(() => {
|
||||
const map = new Map<string, TagRow[]>();
|
||||
const advanced: TagRow[] = [];
|
||||
for (const t of allTags) {
|
||||
if (!t.category) {
|
||||
advanced.push(t);
|
||||
} else {
|
||||
const arr = map.get(t.category) || [];
|
||||
arr.push(t);
|
||||
map.set(t.category, arr);
|
||||
}
|
||||
}
|
||||
// sort tags inside categories
|
||||
for (const [, arr] of map) arr.sort((a, b) => (b.popularity - a.popularity) || a.name.localeCompare(b.name));
|
||||
advanced.sort((a, b) => (b.popularity - a.popularity) || a.name.localeCompare(b.name));
|
||||
return { categories: Array.from(map.keys()).sort((a, b) => a.localeCompare(b)), byCat: map, advanced };
|
||||
}, [allTags]);
|
||||
|
||||
// Filter categories and tags by query; hide categories with zero results. Keep selected tags visible.
|
||||
const filtered = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const categories: string[] = [];
|
||||
const byCat = new Map<string, TagRow[]>();
|
||||
for (const cat of grouped.categories) {
|
||||
const pool = (grouped.byCat.get(cat) || []);
|
||||
const list = q ? pool.filter((t) => t.name.toLowerCase().includes(q)) : pool;
|
||||
if (list.length > 0) {
|
||||
categories.push(cat);
|
||||
byCat.set(cat, list);
|
||||
}
|
||||
}
|
||||
const advPool = grouped.advanced;
|
||||
const advanced = q ? advPool.filter((t) => t.name.toLowerCase().includes(q)) : advPool;
|
||||
return { categories, byCat, advanced };
|
||||
}, [grouped, query, value]);
|
||||
|
||||
// Ensure active category always has results; pick the first available when query changes
|
||||
React.useEffect(() => {
|
||||
const hasActive = activeCategory === "advanced"
|
||||
? filtered.advanced.length > 0
|
||||
: !!activeCategory && filtered.byCat.get(activeCategory)?.length;
|
||||
if (!hasActive && categoriesPaneFocused) {
|
||||
if (filtered.categories.length > 0) setActiveCategory(filtered.categories[0]);
|
||||
else if (filtered.advanced.length > 0) setActiveCategory("advanced");
|
||||
else setActiveCategory(null);
|
||||
}
|
||||
}, [filtered, activeCategory, categoriesPaneFocused]);
|
||||
|
||||
// Scroll category into view when active changes
|
||||
React.useEffect(() => {
|
||||
const el = activeCategory ? categoryRefs.current[activeCategory] : null;
|
||||
if (el) {
|
||||
try { el.scrollIntoView({ block: 'nearest' }); } catch {}
|
||||
}
|
||||
}, [activeCategory]);
|
||||
|
||||
|
||||
// Scroll active tag into view
|
||||
React.useEffect(() => {
|
||||
if (activeTagIndex == null) return;
|
||||
const el = tagItemRefs.current[activeTagIndex];
|
||||
if (el) {
|
||||
try { el.scrollIntoView({ block: 'nearest' }); } catch {}
|
||||
}
|
||||
}, [activeTagIndex]);
|
||||
|
||||
function toggleTag(name: string) {
|
||||
onChange(value.includes(name) ? value.filter((v) => v !== name) : [...value, name]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{/* Selected tag pills */}
|
||||
<div className="flex max-h-24 flex-wrap gap-2 overflow-auto pr-1">
|
||||
{value.length > 0 ? value.map((t) => {
|
||||
const cat = grouped.categories.find((c) => (grouped.byCat.get(c) || []).some((r) => r.name === t)) || (grouped.advanced.some((r) => r.name === t) ? "Advanced" : undefined);
|
||||
const Icon = getCategoryIcon(cat === "Advanced" ? null : cat);
|
||||
return (
|
||||
<span key={t} className="inline-flex items-center gap-1 rounded-full bg-[var(--surface-2)] px-2 py-1 text-xs ring-1 ring-[var(--border)]">
|
||||
{Icon ? <Icon className="h-3.5 w-3.5 opacity-80" /> : null}
|
||||
{t}
|
||||
<button type="button" onClick={() => toggleTag(t)} className="ml-1 text-foreground/70 hover:text-foreground hover:cursor-pointer"><FaTimes className="h-3 w-3" /></button>
|
||||
</span>
|
||||
);
|
||||
}) : <div className="px-2 py-0.5 text-sm text-foreground/60">No tags selected</div>}
|
||||
</div>
|
||||
|
||||
{/* Persistent selector */}
|
||||
<div className="overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)]/80 backdrop-blur-xl shadow-xl">
|
||||
{/* Search input */}
|
||||
<div className="border-b border-[var(--border)] p-2">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const first = filtered.categories[0] || (filtered.advanced.length > 0 ? 'advanced' : null);
|
||||
if (!activeCategory && first) setActiveCategory(first);
|
||||
categoriesContainerRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
placeholder={value.length ? "Search tags" : "Search tags (e.g. QoL, Challenge)"}
|
||||
className="w-full bg-transparent px-2 text-sm placeholder:text-foreground/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex h-80 divide-x divide-[var(--border)]">
|
||||
{/* Categories */}
|
||||
<div
|
||||
ref={categoriesContainerRef}
|
||||
tabIndex={0}
|
||||
onFocus={() => setCategoriesPaneFocused(true)}
|
||||
onBlur={() => setCategoriesPaneFocused(false)}
|
||||
onMouseLeave={() => setCategoriesPaneFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
const cats = [...filtered.categories, ...(filtered.advanced.length > 0 ? ['advanced'] : [])];
|
||||
const idx = activeCategory ? cats.indexOf(activeCategory) : -1;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = Math.min(cats.length - 1, (idx < 0 ? 0 : idx + 1));
|
||||
if (cats[next]) setActiveCategory(cats[next]);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = Math.max(0, (idx < 0 ? 0 : idx - 1));
|
||||
if (idx === 0) {
|
||||
setActiveCategory(null);
|
||||
setActiveTagIndex(null);
|
||||
searchInputRef.current?.focus();
|
||||
} else if (cats[prev]) {
|
||||
setActiveCategory(cats[prev]);
|
||||
}
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const key = cats[idx >= 0 ? idx : 0];
|
||||
if (key) setActiveCategory(key);
|
||||
setActiveTagIndex(0);
|
||||
tagsContainerRef.current?.focus();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const key = cats[idx >= 0 ? idx : 0];
|
||||
if (key) setActiveCategory(key);
|
||||
}
|
||||
}}
|
||||
role="listbox"
|
||||
aria-label="Tag categories"
|
||||
className="w-52 max-w-[60vw] overflow-auto p-2 outline-none"
|
||||
>
|
||||
<div className="mb-1 px-1 text-xs uppercase tracking-wider text-foreground/60">Categories</div>
|
||||
<div className="flex flex-col">
|
||||
{filtered.categories.map((cat) => {
|
||||
const Icon = CATEGORY_ICONS[cat];
|
||||
return (
|
||||
<div
|
||||
key={cat}
|
||||
ref={(el) => { categoryRefs.current[cat] = el; }}
|
||||
role="option"
|
||||
aria-selected={activeCategory === cat}
|
||||
onMouseEnter={() => setActiveCategory(cat)}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={`flex items-center justify-between rounded px-2 py-1.5 text-left text-sm ${
|
||||
activeCategory === cat
|
||||
? (categoriesPaneFocused ? 'bg-black/5 dark:bg-white/10' : 'bg-zinc-800/5 dark:bg-zinc-200/5 ring-1 ring-black/10 dark:ring-white/20')
|
||||
: 'hover:bg-black/5 dark:hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate inline-flex items-center gap-2">{Icon ? <Icon className="h-4 w-4 opacity-80" /> : null}{cat}</span>
|
||||
</div>
|
||||
);})}
|
||||
{filtered.advanced.length > 0 && (
|
||||
<div
|
||||
ref={(el) => { categoryRefs.current['advanced'] = el; }}
|
||||
role="option"
|
||||
aria-selected={activeCategory === 'advanced'}
|
||||
onMouseEnter={() => setActiveCategory('advanced')}
|
||||
onClick={() => setActiveCategory('advanced')}
|
||||
className={`mt-1 flex items-center justify-between rounded px-2 py-1.5 text-left text-sm ${
|
||||
activeCategory === 'advanced'
|
||||
? (categoriesPaneFocused ? 'bg-black/5 dark:bg-white/10' : 'bg-zinc-800/5 dark:bg-zinc-200/5 ring-1 ring-black/10 dark:ring-white/20')
|
||||
: 'hover:bg-black/5 dark:hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2"><MdTune className="h-4 w-4" />Advanced</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div
|
||||
ref={tagsContainerRef}
|
||||
tabIndex={0}
|
||||
onMouseLeave={() => setActiveTagIndex(null)}
|
||||
onBlur={() => setActiveTagIndex(null)}
|
||||
onKeyDown={(e) => {
|
||||
const list = activeCategory
|
||||
? (activeCategory === 'advanced' ? filtered.advanced : (filtered.byCat.get(activeCategory) || []))
|
||||
: [];
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (list.length > 0) setActiveTagIndex((i) => (i == null ? 0 : Math.min(list.length - 1, (i ?? 0) + 1)));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (list.length > 0) setActiveTagIndex((i) => (i == null ? 0 : Math.max(0, (i ?? 0) - 1)));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeTagIndex != null && list[activeTagIndex]) toggleTag(list[activeTagIndex].name);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
setActiveTagIndex(null);
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
categoriesContainerRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
role="listbox"
|
||||
aria-label="Tags"
|
||||
className="min-w-[18rem] max-w-[70vw] overflow-auto p-2 outline-none"
|
||||
>
|
||||
<div className="mb-1 px-1 text-xs uppercase tracking-wider text-foreground/60">{activeCategory === "advanced" ? "Advanced" : (activeCategory || "Pick a category")}</div>
|
||||
<div className="grid gap-1 pr-1">
|
||||
{(activeCategory
|
||||
? (activeCategory === "advanced" ? filtered.advanced : (filtered.byCat.get(activeCategory) || []))
|
||||
: []
|
||||
).map((t, idx) => (
|
||||
<div
|
||||
key={t.id}
|
||||
ref={(el) => { tagItemRefs.current[idx] = el; }}
|
||||
role="option"
|
||||
aria-selected={activeTagIndex === idx}
|
||||
onMouseEnter={() => setActiveTagIndex(idx)}
|
||||
onClick={() => toggleTag(t.name)}
|
||||
className={`flex items-center justify-between rounded px-2 py-1.5 text-sm ${activeTagIndex === idx ? 'bg-black/5 dark:bg-white/10' : 'hover:bg-black/5 dark:hover:bg-white/10'}`}
|
||||
>
|
||||
<span className="truncate">{t.name}</span>
|
||||
<input type="checkbox" readOnly checked={value.includes(t.name)} className="h-4 w-4 accent-[var(--accent)]" />
|
||||
</div>
|
||||
))}
|
||||
{!activeCategory && (
|
||||
<div className="px-2 py-1.5 text-sm text-foreground/60">Select a category</div>
|
||||
)}
|
||||
{activeCategory && (activeCategory === "advanced" ? filtered.advanced.length === 0 : (filtered.byCat.get(activeCategory)?.length || 0) === 0) && (
|
||||
<div className="px-2 py-1.5 text-sm text-foreground/60">No results</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{loading && <div className="text-xs text-foreground/60">Loading tags…</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user