alternative-dropdowns: swapped out the native html select component for a new primitive

This commit is contained in:
jtb9 2025-12-15 07:17:10 -05:00 committed by Justin Barnyak
parent a013565c6f
commit 39e840beb6
5 changed files with 152 additions and 58 deletions

View File

@ -4,6 +4,7 @@ import React, { useActionState } from "react";
import { useSearchParams } from "next/navigation";
import { validateEmail } from "@/utils/auth";
import { sendContact, type ContactActionState } from "@/app/contact/actions";
import Select from "@/components/Primitives/Select";
type Topic =
| "general"
@ -70,17 +71,16 @@ export default function ContactForm() {
)}
<div className="grid gap-2">
<label htmlFor="topic" className="text-sm text-foreground/80">Topic</label>
<select
<Select
id="topic"
name="topic"
value={topic}
onChange={(e) => setTopic(e.target.value as Topic)}
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
{(Object.keys(topicLabels) as Topic[]).map((key) => (
<option key={key} value={key}>{topicLabels[key]}</option>
))}
</select>
onChange={(value) => setTopic(value as Topic)}
options={(Object.keys(topicLabels) as Topic[]).map((key) => ({
value: key,
label: topicLabels[key],
}))}
/>
<span className="text-xs text-foreground/60">
Choose the most relevant topic so we can help you better.
</span>

View File

@ -5,6 +5,7 @@ import Link from "next/link";
import { FiExternalLink, FiEdit2, FiTrash2, FiChevronLeft, FiChevronRight, FiArrowDown, FiSearch, FiLoader, FiDownload, FiInfo, FiBarChart2 } from "react-icons/fi";
import { getArchives, deleteArchive } from "@/app/dashboard/archives/actions";
import { baseRoms } from "@/data/baseRoms";
import Select from "@/components/Primitives/Select";
type Archive = {
slug: string;
@ -118,24 +119,26 @@ export default function ArchivesList({ initialData, isAdmin = false }: { initial
)}
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
<select
<Select
value={filter}
onChange={(e) => setFilter(e.target.value as "all" | "downloadable" | "informational")}
className="w-full md:w-auto rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
<option value="all">All Archives</option>
<option value="downloadable">Downloadable</option>
<option value="informational">Informational</option>
</select>
<select
onChange={(value) => setFilter(value as "all" | "downloadable" | "informational")}
className="w-full md:w-auto"
options={[
{ value: "all", label: "All Archives" },
{ value: "downloadable", label: "Downloadable" },
{ value: "informational", label: "Informational" },
]}
/>
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="w-full md:w-auto rounded-md bg-[var(--surface-2)] px-3 py-2 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
<option value="created_at">Sort by date</option>
<option value="title">Sort by title</option>
<option value="original_author">Sort by author</option>
</select>
onChange={(value) => setSortBy(value as any)}
className="w-full md:w-auto"
options={[
{ value: "created_at", label: "Sort by date" },
{ value: "title", label: "Sort by title" },
{ value: "original_author", label: "Sort by author" },
]}
/>
<button
type="button"
onClick={() => setSortOrder(sortOrder === "asc" ? "desc" : "asc")}

View File

@ -10,6 +10,7 @@ import Image from "next/image";
import { createClient } from "@/utils/supabase/client";
import { updateHack, saveHackCovers, presignCoverUpload } from "@/app/hack/actions";
import SortableCovers from "@/components/Hack/SortableCovers";
import Select from "@/components/Primitives/Select";
interface HackEditFormProps {
slug: string;
@ -393,11 +394,15 @@ export default function HackEditForm({ slug, initial }: HackEditFormProps) {
</div>
)}
</div>
<select value={language} onChange={(e) => setLanguage(e.target.value)} className={`h-11 rounded-md px-3 text-sm ring-1 ring-inset focus:outline-none focus:ring-2 ${languageChanged ? 'ring-[var(--ring)] bg-[var(--surface-2)]' : 'bg-[var(--surface-2)] ring-[var(--border)]'}`}>
{['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => (
<option key={l} value={l}>{l}</option>
))}
</select>
<Select
value={language}
onChange={setLanguage}
className={languageChanged ? 'ring-[var(--ring)]' : ''}
options={['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => ({
value: l,
label: l,
}))}
/>
</div>
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Current version</label>

View File

@ -18,6 +18,7 @@ import { useAuthContext } from "@/contexts/AuthContext";
import { useBaseRoms } from "@/contexts/BaseRomContext";
import TagSelector from "@/components/Submit/TagSelector";
import BinFile from "rom-patcher-js/rom-patcher-js/modules/BinFile.js";
import Select from "@/components/Primitives/Select";
import BPS from "rom-patcher-js/rom-patcher-js/modules/RomPatcher.format.bps.js";
import { sha1Hex } from "@/utils/hash";
import { platformAccept, setDraftCovers, getDraftCovers, deleteDraftCovers } from "@/utils/idb";
@ -733,17 +734,16 @@ export default function HackSubmitForm({
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Platform <span className="text-red-500">*</span></label>
{!isDummy ? (
<select
<Select
value={platform}
onChange={(e) => { if ((newCoverFiles.length) > 0) return; setPlatform(e.target.value as any); setBaseRom(""); }}
onChange={(value) => { if ((newCoverFiles.length) > 0) return; setPlatform(value as any); setBaseRom(""); }}
disabled={newCoverFiles.length > 0}
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-50"
>
<option value="" disabled>Select platform</option>
{(["GB","GBC","GBA","NDS"] as const).map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
placeholder="Select platform"
options={(["GB","GBC","GBA","NDS"] as const).map(p => ({
value: p,
label: p,
}))}
/>
) : (
<div className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">{platform || ""}</div>
)}
@ -755,19 +755,16 @@ export default function HackSubmitForm({
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Base ROM <span className="text-red-500">*</span></label>
{!isDummy ? (
<select
<Select
value={baseRom}
onChange={(e) => setBaseRom(e.target.value)}
onChange={setBaseRom}
disabled={!platform}
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-50"
>
<option value="" disabled>{platform ? "Select base rom" : "Select platform first"}</option>
{baseRoms.filter(r => !platform || r.platform === platform).map(({ id, name, region }) => (
<option key={id} value={id}>
{name.replace('Pokémon ', '')} ({region})
</option>
))}
</select>
placeholder={platform ? "Select base rom" : "Select platform first"}
options={baseRoms.filter(r => !platform || r.platform === platform).map(({ id, name, region }) => ({
value: id,
label: `${name.replace('Pokémon ', '')} (${region})`,
}))}
/>
) : (
<div className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">{baseRoms.find(r=>r.id===baseRom)?.name || baseRom}</div>
)}
@ -776,16 +773,15 @@ export default function HackSubmitForm({
<div className="grid gap-2">
<label className="text-sm text-foreground/80">Language <span className="text-red-500">*</span></label>
{!isDummy ? (
<select
<Select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
>
<option value="" disabled>Select language</option>
{['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => (
<option key={l} value={l}>{l}</option>
))}
</select>
onChange={setLanguage}
placeholder="Select language"
options={['English','Spanish','French','German','Italian','Portuguese','Japanese','Chinese','Korean','Other'].map(l => ({
value: l,
label: l,
}))}
/>
) : (
<div role="textbox" aria-disabled className="h-11 rounded-md bg-[var(--surface-2)] px-3 text-sm ring-1 ring-inset ring-[var(--border)] flex items-center text-foreground/60 select-none">{language}</div>
)}

View File

@ -0,0 +1,90 @@
"use client";
import React, { Fragment } from "react";
import { Listbox, ListboxButton, ListboxOptions, ListboxOption, Transition } from "@headlessui/react";
import { FiChevronDown, FiCheck } from "react-icons/fi";
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface SelectProps {
value: string;
onChange: (value: string) => void;
options: SelectOption[];
placeholder?: string;
disabled?: boolean;
className?: string;
id?: string;
name?: string;
}
export default function Select({
value,
onChange,
options,
placeholder = "Select an option",
disabled = false,
className = "",
id,
name,
}: SelectProps) {
const selectedOption = options.find((opt) => opt.value === value);
return (
<Listbox value={value} onChange={onChange} disabled={disabled}>
{({ open }) => (
<div className="relative">
<ListboxButton
id={id}
className={`relative h-11 w-full rounded-md bg-[var(--surface-2)] px-3 pr-10 text-left text-sm ring-1 ring-inset ring-[var(--border)] focus:outline-none focus:ring-2 focus:ring-[var(--ring)] disabled:opacity-50 ${className}`}
>
<span className={`block truncate ${selectedOption ? "" : "text-foreground/60"}`}>
{selectedOption?.label || placeholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<FiChevronDown className={`h-4 w-4 text-foreground/60 transition-transform ${open ? "rotate-180" : ""}`} aria-hidden="true" />
</span>
</ListboxButton>
{name && <input type="hidden" name={name} value={value} />}
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute z-10 mt-1 max-h-60 min-w-full max-w-[400px] w-max overflow-auto rounded-md bg-background/95 backdrop-blur-sm py-1 text-sm shadow-lg ring-1 ring-[var(--border)] focus:outline-none">
{options.map((option) => (
<ListboxOption
key={option.value}
value={option.value}
disabled={option.disabled}
className={({ focus }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
focus ? "bg-black/5 dark:bg-white/10" : ""
} ${option.disabled ? "opacity-50 cursor-not-allowed" : ""}`
}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? "font-medium" : "font-normal"}`}>
{option.label}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-foreground">
<FiCheck className="h-4 w-4" aria-hidden="true" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</div>
)}
</Listbox>
);
}