Refactor tag filters to dropdowns

This commit is contained in:
Jared Schoeny 2025-10-21 22:50:29 -10:00
parent bfa29d239f
commit 8f98287505
3 changed files with 448 additions and 27 deletions

222
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@headlessui/react": "^2.2.9",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.74.0",
"embla-carousel-react": "8.6.0",
@ -111,6 +112,79 @@
"tslib": "^2.4.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.4"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@headlessui/react": {
"version": "2.2.9",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz",
"integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.16",
"@react-aria/focus": "^3.20.2",
"@react-aria/interactions": "^3.25.0",
"@tanstack/react-virtual": "^3.13.9",
"use-sync-external-store": "^1.5.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
@ -736,6 +810,103 @@
"node": ">= 10"
}
},
"node_modules/@react-aria/focus": {
"version": "3.21.2",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz",
"integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/interactions": "^3.25.6",
"@react-aria/utils": "^3.31.0",
"@react-types/shared": "^3.32.1",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/interactions": {
"version": "3.25.6",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz",
"integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.10",
"@react-aria/utils": "^3.31.0",
"@react-stately/flags": "^3.1.2",
"@react-types/shared": "^3.32.1",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/ssr": {
"version": "3.9.10",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"engines": {
"node": ">= 12"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/utils": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz",
"integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.10",
"@react-stately/flags": "^3.1.2",
"@react-stately/utils": "^3.10.8",
"@react-types/shared": "^3.32.1",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-stately/flags": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
"integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@react-stately/utils": {
"version": "3.10.8",
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz",
"integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-types/shared": {
"version": "3.32.1",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz",
"integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.74.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.74.0.tgz",
@ -1120,6 +1291,33 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
"integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@ -1485,6 +1683,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -4259,6 +4466,12 @@
"node": ">=8"
}
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz",
@ -4478,6 +4691,15 @@
"node": ">= 10.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",

View File

@ -13,6 +13,7 @@
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@headlessui/react": "^2.2.9",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.74.0",
"embla-carousel-react": "8.6.0",

View File

@ -1,16 +1,44 @@
"use client";
import React from "react";
import React, { Fragment } from "react";
import HackCard from "@/components/HackCard";
import { createClient } from "@/utils/supabase/client";
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 { 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";
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();
const [query, setQuery] = React.useState("");
const [tag, setTag] = React.useState<string | null>(null);
const [selectedTags, setSelectedTags] = React.useState<string[]>([]);
const [selectedBaseRoms, setSelectedBaseRoms] = React.useState<string[]>([]);
const [sort, setSort] = React.useState("popular");
const [hacks, setHacks] = React.useState<any[]>([]);
const [tags, setTags] = React.useState<string[]>([]);
const [tagGroups, setTagGroups] = React.useState<Record<string, string[]>>({});
const [ungroupedTags, setUngroupedTags] = React.useState<string[]>([]);
React.useEffect(() => {
const run = async () => {
@ -49,7 +77,7 @@ export default function DiscoverBrowser() {
}
const { data: tagRows } = await supabase
.from("hack_tags")
.select("hack_slug,tags(name)")
.select("hack_slug,tags(name,category)")
.in("hack_slug", slugs);
const tagsBySlug = new Map<string, string[]>();
(tagRows || []).forEach((r: any) => {
@ -58,6 +86,10 @@ export default function DiscoverBrowser() {
arr.push(r.tags.name);
tagsBySlug.set(r.hack_slug, arr);
});
// Fetch all tags with category to build UI groups
const { data: allTagRows } = await supabase
.from("tags")
.select("name,category");
const { data: profiles } = await supabase
.from("profiles")
.select("id,username");
@ -78,9 +110,29 @@ export default function DiscoverBrowser() {
}));
setHacks(mapped);
const tagSet = new Set<string>();
mapped.forEach((h) => h.tags.forEach((t) => tagSet.add(t)));
setTags(Array.from(tagSet).sort());
if (allTagRows) {
const groups: Record<string, string[]> = {};
const ungrouped: string[] = [];
const unique = new Set<string>();
// Build groups from authoritative tags table, so we include tags not present in current results too
for (const row of allTagRows as any[]) {
const name: string = row.name;
if (unique.has(name)) continue;
unique.add(name);
const category: string | null = row.category ?? null;
if (category) {
if (!groups[category]) groups[category] = [];
groups[category].push(name);
} else {
ungrouped.push(name);
}
}
// Sort for stable UI
Object.keys(groups).forEach((k) => groups[k].sort((a, b) => a.localeCompare(b)));
ungrouped.sort((a, b) => a.localeCompare(b));
setTagGroups(groups);
setUngroupedTags(ungrouped);
}
};
run();
}, [sort]);
@ -95,12 +147,35 @@ export default function DiscoverBrowser() {
(h.description || "").toLowerCase().includes(q)
);
}
if (tag) out = out.filter((h) => h.tags.includes(tag));
// AND filter across selected tags: hack must include all selectedTags
if (selectedTags.length > 0) {
out = out.filter((h) => selectedTags.every((t) => h.tags.includes(t)));
}
// OR filter across base roms: hack's baseRomId must be in selectedBaseRoms
if (selectedBaseRoms.length > 0) {
out = out.filter((h) => selectedBaseRoms.includes(h.baseRomId));
}
return out;
}, [hacks, query, tag]);
}, [hacks, query, selectedTags, selectedBaseRoms]);
function toggleTag(name: string) {
setSelectedTags((prev) => (prev.includes(name) ? prev.filter((t) => t !== name) : [...prev, name]));
}
function clearTags() {
setSelectedTags([]);
}
function toggleBaseRom(id: string) {
setSelectedBaseRoms((prev) => (prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]));
}
function clearBaseRoms() {
setSelectedBaseRoms([]);
}
return (
<div>
<div className="max-w-[1200px] mx-auto">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<input
@ -120,30 +195,58 @@ export default function DiscoverBrowser() {
</select>
</div>
<div className="mt-6 flex flex-wrap gap-2">
<button
onClick={() => setTag(null)}
className={`rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors shadow-sm ${
tag === null
? "bg-[var(--accent)]/15 text-[var(--foreground)] ring-[var(--accent)]/35 shadow-[inset_0_1px_0_rgba(0,0,0,0.04)]"
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10 shadow-[inset_0_1px_0_rgba(0,0,0,0.03)]"
}`}
>
All
</button>
{tags.map((t) => (
{/* Unified filter section: Base ROM dropdown first, category dropdowns next, ungrouped tags last */}
<div className="mt-6 flex flex-wrap items-center gap-2">
<MultiSelectDropdown
icon={BsSdCardFill}
label="Base ROM"
options={baseRoms.map((b) => ({ id: b.id, name: b.name }))}
values={selectedBaseRoms}
onChange={setSelectedBaseRoms}
/>
{Object.keys(tagGroups)
.sort((a, b) => a.localeCompare(b))
.map((cat) => (
<MultiSelectDropdown
key={cat}
icon={CATEGORY_ICON[cat]}
label={cat}
options={tagGroups[cat].map((t) => ({ id: t, name: t }))}
values={selectedTags.filter((t) => tagGroups[cat].includes(t))}
onChange={(vals) => {
// Replace selections for this category while keeping others
setSelectedTags((prev) => {
const others = prev.filter((t) => !tagGroups[cat].includes(t));
return [...others, ...vals];
});
}}
/>
))}
{/* Ungrouped tags as individual pills at the end */}
{ungroupedTags.map((t) => (
<button
key={t}
onClick={() => setTag(t)}
className={`rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors shadow-sm ${
tag === t
? "bg-[var(--accent)]/15 text-[var(--foreground)] ring-[var(--accent)]/35 shadow-[inset_0_1px_0_rgba(0,0,0,0.04)]"
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10 shadow-[inset_0_1px_0_rgba(0,0,0,0.03)]"
onClick={() => toggleTag(t)}
className={`rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors ${
selectedTags.includes(t)
? "bg-[var(--accent)]/15 text-[var(--foreground)] ring-[var(--accent)]/35"
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
}`}
>
{t}
</button>
))}
{(selectedTags.length > 0 || selectedBaseRoms.length > 0) && (
<button
onClick={() => {
clearTags();
clearBaseRoms();
}}
className="ml-2 rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
>
Clear filters
</button>
)}
</div>
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
@ -155,4 +258,99 @@ export default function DiscoverBrowser() {
);
}
interface MultiSelectOption {
id: string;
name: string;
}
interface MultiSelectDropdownProps {
icon?: IconType;
label: string;
options: MultiSelectOption[];
values: string[];
onChange: (next: string[]) => void;
}
function MultiSelectDropdown({
icon: Icon,
label,
options,
values,
onChange,
}: MultiSelectDropdownProps) {
const { refs, floatingStyles, update } = useFloating({
placement: "bottom-start",
strategy: "fixed",
middleware: [
offset(8),
flip({ padding: 8 }),
shift({ padding: 8 }),
size({
apply({ availableWidth, elements }) {
Object.assign(elements.floating.style, {
maxWidth: `${Math.min(availableWidth, 420)}px`,
});
},
}),
],
});
React.useEffect(() => {
const reference = refs.reference.current;
const floating = refs.floating.current;
if (!reference || !floating) return;
return autoUpdate(reference, floating, update);
}, [refs.reference, refs.floating, update]);
const nameById = React.useMemo(() => {
const map = new Map<string, string>();
options.forEach((o) => map.set(o.id, o.name));
return map;
}, [options]);
const selectedNames = values.map((v) => nameById.get(v) || v);
const hasSelection = values.length > 0;
return (
<Listbox value={values} onChange={onChange} multiple>
<div className="relative">
<ListboxButton
ref={refs.setReference}
className={`flex max-w-[22rem] cursor-pointer select-none items-center gap-2 truncate rounded-full px-3 py-1 text-sm ring-1 ring-inset transition-colors ${
hasSelection
? "bg-[var(--accent)]/15 text-[var(--foreground)] ring-[var(--accent)]/35"
: "bg-[var(--surface-2)] text-foreground/80 ring-[var(--border)] hover:bg-black/5 dark:hover:bg-white/10"
} data-open:ring-2 data-open:ring-[var(--ring)]`}
>
{Icon ? <Icon className="h-4 w-4" /> : null}
<span className="truncate">
{selectedNames.length > 0 ? `${label}: ${selectedNames.join(", ")}` : label}
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions ref={refs.setFloating} style={floatingStyles} className="z-50 max-h-64 min-w-[14rem] overflow-auto rounded-md border border-[var(--border)] bg-white/70 dark:bg-zinc-800/70 backdrop-blur-xl p-1 shadow-lg focus:outline-none">
{options.map((opt) => (
<ListboxOption
key={opt.id}
value={opt.id}
className={({ active }) =>
`cursor-pointer select-none rounded px-2 py-1 text-sm ${active ? "bg-black/5 dark:bg-white/10" : ""}`
}
>
{({ selected }) => (
<div className="flex items-center gap-2">
<input type="checkbox" readOnly checked={selected} className="h-4 w-4 accent-[var(--accent)]" />
<span className="text-foreground/90">{opt.name}</span>
</div>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</div>
</Listbox>
);
}