1
0
Fork 0

feat(ui): searchable combobox across data-driven dropdowns

- new ui/combobox primitive (popover plus cmdk): search input, scrolling
  list, trigger-width popover, per-option disabled and hint lines
- converts every data-driven single-value select site-wide (blueprints,
  factions, accounts, assignments, NPCs, structures, locker items, seats,
  missions, ranks, resources, mod lists, event targets, sprints)
- long multi-select checkbox menus (event log types, kanban labels) gain
  search inputs following the resource picker pattern
- static short enum selects stay as plain selects
This commit is contained in:
Jason Fraley 2026-09-17 16:28:17 -04:00
parent 2bc61fe211
commit 947a5c1bbf
21 changed files with 651 additions and 597 deletions

View file

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ArrowLeftRightIcon, Loader2Icon } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Combobox } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -16,7 +17,14 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { requestTransfer } from "@/app/(frontend)/transfers/actions";
@ -199,22 +207,20 @@ export function RequestTransferDialog({
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1.5">
<Label htmlFor="transfer-source">Current assignment</Label>
<Select value={sourceId} onValueChange={setSourceId} disabled={pending}>
<SelectTrigger id="transfer-source">
<SelectValue placeholder="Select current assignment" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{requestableAssignments.map((assignment) => (
<SelectItem key={assignment.id} value={String(assignment.id)}>
{assignment.callsign
? `${assignment.name} · ${assignment.callsign}`
: assignment.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Combobox
id="transfer-source"
value={sourceId}
onValueChange={setSourceId}
disabled={pending}
placeholder="Select current assignment"
searchPlaceholder="Search assignments..."
options={requestableAssignments.map((assignment) => ({
value: String(assignment.id),
label: assignment.callsign
? `${assignment.name} · ${assignment.callsign}`
: assignment.name,
}))}
/>
</div>
<div className="flex flex-col gap-1.5" aria-busy={loadingDestinations}>
@ -222,26 +228,20 @@ export function RequestTransferDialog({
{loadingDestinations ? (
<Skeleton className="h-9 w-full" />
) : destinations.length > 0 ? (
<Select
<Combobox
id="transfer-destination"
value={destinationId}
onValueChange={setDestinationId}
disabled={pending}
>
<SelectTrigger id="transfer-destination">
<SelectValue placeholder="Select destination assignment" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{destinations.map((assignment) => (
<SelectItem key={assignment.id} value={String(assignment.id)}>
{assignment.callsign
? `${assignment.name} · ${assignment.callsign}`
: assignment.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
placeholder="Select destination assignment"
searchPlaceholder="Search assignments..."
options={destinations.map((assignment) => ({
value: String(assignment.id),
label: assignment.callsign
? `${assignment.name} · ${assignment.callsign}`
: assignment.name,
}))}
/>
) : (
<Alert>
<AlertDescription>
@ -271,10 +271,18 @@ export function RequestTransferDialog({
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setInternalOpen(false)} disabled={pending}>
<Button
type="button"
variant="outline"
onClick={() => setInternalOpen(false)}
disabled={pending}
>
Cancel
</Button>
<Button type="submit" disabled={pending || loadingDestinations || destinations.length === 0}>
<Button
type="submit"
disabled={pending || loadingDestinations || destinations.length === 0}
>
{pending && <Loader2Icon data-icon="inline-start" className="animate-spin" />}
{pending ? "Submitting..." : "Submit request"}
</Button>

View file

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { ArrowDownLeftIcon, ArrowRightLeftIcon, ArrowUpRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
@ -164,18 +165,17 @@ export function BankTransactionDialog({
{mode === "transfer" && (
<div className="flex flex-col gap-1.5">
<Label>Destination account</Label>
<Select value={toId} onValueChange={setToId}>
<SelectTrigger>
<SelectValue placeholder="Select account" />
</SelectTrigger>
<SelectContent>
{otherAccounts.map((a) => (
<SelectItem key={a.id} value={String(a.id)}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
value={toId}
onValueChange={setToId}
placeholder="Select account"
searchPlaceholder="Search accounts..."
emptyMessage="No other accounts."
options={otherAccounts.map((a) => ({
value: String(a.id),
label: a.name,
}))}
/>
</div>
)}
<div className="flex flex-col gap-1.5">

View file

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label";
import {
Select,
@ -117,18 +118,16 @@ export function CreateAccountDialog({
{accountType === "faction" && (
<div className="flex flex-col gap-1.5">
<Label>Owner faction</Label>
<Select value={ownerFactionId} onValueChange={setOwnerFactionId}>
<SelectTrigger>
<SelectValue placeholder="Select faction" />
</SelectTrigger>
<SelectContent>
{factions.map((f) => (
<SelectItem key={f.id} value={String(f.id)}>
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
value={ownerFactionId}
onValueChange={setOwnerFactionId}
placeholder="Select faction"
searchPlaceholder="Search factions..."
options={factions.map((f) => ({
value: String(f.id),
label: f.name,
}))}
/>
</div>
)}
{accountType === "personal" && !isManager && (

View file

@ -15,15 +15,8 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useRouter } from "next/navigation";
import type { LaborClassificationOption, StructureStaffingOption } from "@/lib/base";
import { currencyLabel, type CurrencyConfig } from "@/lib/banking/format";
@ -353,61 +346,51 @@ export function HireStaffDialog({
{fixedNpc?.name ?? `NPC #${fixedNpcId}`}
</p>
) : (
<Select value={npcId} onValueChange={setNpcId}>
<SelectTrigger>
<SelectValue placeholder="Select an NPC" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{npcs.map((npc) => (
<SelectItem key={npc.id} value={String(npc.id)}>
{npc.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Combobox
value={npcId}
onValueChange={setNpcId}
placeholder="Select an NPC"
searchPlaceholder="Search NPCs..."
emptyMessage="No candidates available."
options={npcs.map((npc) => ({
value: String(npc.id),
label: npc.name,
}))}
/>
)}
</div>
)}
<div className="flex flex-col gap-1.5">
<Label>Structure</Label>
<Select value={structureId} onValueChange={handleStructureChange}>
<SelectTrigger>
<SelectValue placeholder="Select a structure" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{structures.map((structure) => (
<SelectItem key={structure.id} value={String(structure.id)}>
{structure.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Combobox
value={structureId}
onValueChange={handleStructureChange}
placeholder="Select a structure"
searchPlaceholder="Search structures..."
options={structures.map((structure) => ({
value: String(structure.id),
label: structure.name,
}))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Position type</Label>
<Select value={positionType} onValueChange={handlePositionTypeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{fixedNpcId == null && approvedClassifications.length > 0 && (
<SelectItem value="labor">Labor</SelectItem>
)}
{approvedPositionTypes.map((position) => (
<SelectItem key={position.value} value={position.value}>
{position.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Combobox
value={positionType}
onValueChange={handlePositionTypeChange}
searchPlaceholder="Search positions..."
options={[
...(fixedNpcId == null && approvedClassifications.length > 0
? [{ value: "labor", label: "Labor" }]
: []),
...approvedPositionTypes.map((position) => ({
value: position.value,
label: position.label,
})),
]}
/>
{selectedStructure &&
approvedClassifications.length === 0 &&
approvedPositionTypes.length === 0 && (
@ -421,20 +404,15 @@ export function HireStaffDialog({
<>
<div className="flex flex-col gap-1.5">
<Label>Classification</Label>
<Select value={laborClassification} onValueChange={handleClassificationChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{approvedClassifications.map((classification) => (
<SelectItem key={classification.id} value={String(classification.id)}>
{classification.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Combobox
value={laborClassification}
onValueChange={handleClassificationChange}
searchPlaceholder="Search classifications..."
options={approvedClassifications.map((classification) => ({
value: String(classification.id),
label: classification.name,
}))}
/>
{selectedStructure && approvedClassifications.length === 0 && (
<p className="text-sm text-muted-foreground">
This structure has no approved labor positions.

View file

@ -4,6 +4,7 @@ import * as React from "react";
import { ChevronLeft, ChevronRight, RotateCcw, Search } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Combobox } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
@ -305,19 +306,20 @@ export default function EventLogTable() {
</DropdownMenuContent>
</DropdownMenu>
<Select value={targetCollection} onValueChange={setTargetCollection}>
<SelectTrigger className="h-8 w-44">
<SelectValue placeholder="Target" />
</SelectTrigger>
<SelectContent className="max-h-72">
<SelectItem value="all">All targets</SelectItem>
{TARGET_COLLECTION_OPTIONS.map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
className="h-8 w-44"
value={targetCollection}
onValueChange={setTargetCollection}
placeholder="Target"
searchPlaceholder="Search targets..."
options={[
{ value: "all", label: "All targets" },
...TARGET_COLLECTION_OPTIONS.map(([value, label]) => ({
value,
label,
})),
]}
/>
<Select value={source} onValueChange={(v) => setSource(v as SourceFilter)}>
<SelectTrigger className="h-8 w-36">
@ -442,10 +444,7 @@ export default function EventLogTable() {
Showing {from.toLocaleString()}-{to.toLocaleString()} of {totalDocs.toLocaleString()}
</span>
<div className="flex items-center gap-2">
<Select
value={String(pageSize)}
onValueChange={(v) => setPageSize(Number(v))}
>
<Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}>
<SelectTrigger className="h-8 w-28">
<SelectValue />
</SelectTrigger>

View file

@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import type { Ticket, User } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import { Label } from "@/components/ui/label";
import {
@ -264,23 +265,21 @@ export function TicketDetail({
<div className="flex flex-col gap-1.5">
<Label htmlFor="ticket-assignee">Assignee</Label>
<div className="flex gap-2">
<Select
<Combobox
id="ticket-assignee"
className="min-w-0 flex-1"
value={assignee}
onValueChange={setAssignee}
disabled={controlBusy !== null}
>
<SelectTrigger id="ticket-assignee" className="min-w-0 flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unassigned">Unassigned</SelectItem>
{assigneeOptions.map((option) => (
<SelectItem key={option.id} value={String(option.id)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
searchPlaceholder="Search members..."
options={[
{ value: "unassigned", label: "Unassigned" },
...assigneeOptions.map((option) => ({
value: String(option.id),
label: option.label,
})),
]}
/>
<Button
size="sm"
onClick={() => void applyControl("assignee")}

View file

@ -10,6 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Combobox } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -21,11 +22,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
createFridaySideOp,
updateFridaySideOp,
@ -238,7 +235,9 @@ export function CreateSideOpDialog({
}
function modsInput() {
return requiredModsId === "none" ? { requiredModsId: null } : { requiredModsId: Number(requiredModsId) };
return requiredModsId === "none"
? { requiredModsId: null }
: { requiredModsId: Number(requiredModsId) };
}
async function handleModlistUpload(file: File) {
@ -344,7 +343,8 @@ export function CreateSideOpDialog({
estimatedDuration: Number(estimatedDuration) || 240,
maxPlayers: Number(maxPlayers) || 32,
...modsInput(),
campaignId: campaignChoice === "new" || campaignChoice === "none" ? null : Number(campaignChoice),
campaignId:
campaignChoice === "new" || campaignChoice === "none" ? null : Number(campaignChoice),
newCampaignName: campaignChoice === "new" ? newCampaignName || null : null,
noCampaign: campaignChoice === "none",
...narrativeInput(),
@ -430,25 +430,20 @@ export function CreateSideOpDialog({
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label>Map</Label>
<Select
<Combobox
className="w-full"
value={mapChoice}
onValueChange={(v) => {
setMapChoice(v);
if (v !== "custom" && v !== "") setMapId(v);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a map" />
</SelectTrigger>
<SelectContent>
{maps.map((m) => (
<SelectItem key={m.id} value={String(m.id)}>
{m.name}
</SelectItem>
))}
<SelectItem value="custom">+ Custom map…</SelectItem>
</SelectContent>
</Select>
placeholder="Choose a map"
searchPlaceholder="Search maps..."
options={[
...maps.map((m) => ({ value: String(m.id), label: m.name })),
{ value: "custom", label: "+ Custom map…" },
]}
/>
</div>
<div className="grid gap-1.5">
<Label>Mission type</Label>
@ -490,20 +485,22 @@ export function CreateSideOpDialog({
</div>
<div className="grid gap-1.5">
<Label>Campaign</Label>
<Select value={campaignChoice} onValueChange={setCampaignChoice}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a campaign" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No campaign (one-off op)</SelectItem>
{campaigns.map((c) => (
<SelectItem key={c.id} value={String(c.id)} disabled={c.disabled}>
{c.name}
</SelectItem>
))}
<SelectItem value="new">+ New campaign…</SelectItem>
</SelectContent>
</Select>
<Combobox
className="w-full"
value={campaignChoice}
onValueChange={setCampaignChoice}
placeholder="Choose a campaign"
searchPlaceholder="Search campaigns..."
options={[
{ value: "none", label: "No campaign (one-off op)" },
...campaigns.map((c) => ({
value: String(c.id),
label: c.name,
disabled: c.disabled,
})),
{ value: "new", label: "+ New campaign…" },
]}
/>
</div>
</div>
{mapChoice === "custom" && (
@ -531,19 +528,17 @@ export function CreateSideOpDialog({
<div className="grid gap-1.5">
<Label>Mod list</Label>
<Select value={requiredModsId} onValueChange={setRequiredModsId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="No mod list (vanilla)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No mod list (vanilla)</SelectItem>
{modLists.map((l) => (
<SelectItem key={l.id} value={String(l.id)}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
className="w-full"
value={requiredModsId}
onValueChange={setRequiredModsId}
placeholder="No mod list (vanilla)"
searchPlaceholder="Search mod lists..."
options={[
{ value: "none", label: "No mod list (vanilla)" },
...modLists.map((l) => ({ value: String(l.id), label: l.name })),
]}
/>
<Input
type="file"
accept=".html,.htm,.txt,text/html,text/plain"
@ -555,8 +550,8 @@ export function CreateSideOpDialog({
}}
/>
<p className="text-xs text-muted-foreground">
Upload the mod list file exported from the Arma 3 launcher (.html) or a .txt mod
list, or pick an existing one.
Upload the mod list file exported from the Arma 3 launcher (.html) or a .txt mod list,
or pick an existing one.
</p>
</div>

View file

@ -5,15 +5,9 @@ import { useRouter } from "next/navigation";
import { PlusIcon } from "lucide-react";
import type { Asset } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
@ -86,18 +80,17 @@ export function AddLockerItemDialog({ assets }: AddLockerItemDialogProps) {
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label>Asset</Label>
<Select value={assetId} onValueChange={setAssetId}>
<SelectTrigger>
<SelectValue placeholder="Select an asset" />
</SelectTrigger>
<SelectContent>
{assets.map((a) => (
<SelectItem key={a.id} value={String(a.id)}>
{a.name} · {getAssetTypeLabel(a.assetType)}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
value={assetId}
onValueChange={setAssetId}
placeholder="Select an asset"
searchPlaceholder="Search assets..."
emptyMessage="No assets in your locker."
options={assets.map((a) => ({
value: String(a.id),
label: `${a.name} · ${getAssetTypeLabel(a.assetType)}`,
}))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>Quantity</Label>

View file

@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { Asset } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@ -14,13 +15,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
getAssetTypeLabel,
getAttachmentOptionsForItem,
@ -202,30 +196,24 @@ export function EquipmentEditorDialog({
className="grid grid-cols-[100px_1fr] items-center gap-2"
>
<span className="text-xs text-muted-foreground">{category.label}</span>
<Select
<Combobox
value={installed ? String(installed.id) : "none"}
onValueChange={(v) => handleCategoryPick(category, v)}
disabled={busy}
>
<SelectTrigger>
<SelectValue
placeholder={
categoryOptions.length === 0
? "No compatible items"
: "Choose an attachment"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{categoryOptions.map((o) => (
<SelectItem key={o.assetId} value={String(o.assetId)}>
{o.asset.name} ·{" "}
{o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "installed"}
</SelectItem>
))}
</SelectContent>
</Select>
placeholder={
categoryOptions.length === 0
? "No compatible items"
: "Choose an attachment"
}
searchPlaceholder="Search attachments..."
options={[
{ value: "none", label: "None" },
...categoryOptions.map((o) => ({
value: String(o.assetId),
label: `${o.asset.name} · ${o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "installed"}`,
})),
]}
/>
</div>
);
})}
@ -240,28 +228,22 @@ export function EquipmentEditorDialog({
<Label className="text-sm font-semibold">Skin</Label>
<div className="grid grid-cols-[100px_1fr] items-center gap-2">
<span className="text-xs text-muted-foreground">Applied</span>
<Select
<Combobox
value={installedSkin ? String(installedSkin.id) : "none"}
onValueChange={handleSkinPick}
disabled={busy}
>
<SelectTrigger>
<SelectValue
placeholder={
mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin"
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{mergedSkinOptions.map((o) => (
<SelectItem key={o.assetId} value={String(o.assetId)}>
{o.asset.name} ·{" "}
{o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "applied"}
</SelectItem>
))}
</SelectContent>
</Select>
placeholder={
mergedSkinOptions.length === 0 ? "No compatible skins" : "Choose a skin"
}
searchPlaceholder="Search skins..."
options={[
{ value: "none", label: "None" },
...mergedSkinOptions.map((o) => ({
value: String(o.assetId),
label: `${o.asset.name} · ${o.quantity > 0 ? `${o.quantity.toLocaleString()}×` : "applied"}`,
})),
]}
/>
</div>
{installedSkin && (
<p className="text-xs text-muted-foreground">

View file

@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { Loadout } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@ -160,24 +161,21 @@ export function LoadoutEditor({
return (
<div key={slot.name} className="flex flex-col gap-1.5">
<Label>{slot.label}</Label>
<Select
<Combobox
value={slots[slot.name] ?? ""}
onValueChange={(v) =>
setSlots((prev) => ({ ...prev, [slot.name]: v === "none" ? "" : v }))
}
>
<SelectTrigger>
<SelectValue placeholder={slot.description} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Empty</SelectItem>
{options.map((item) => (
<SelectItem key={item.assetId} value={String(item.assetId)}>
{item.asset.name}
</SelectItem>
))}
</SelectContent>
</Select>
placeholder={slot.description}
searchPlaceholder="Search items..."
options={[
{ value: "none", label: "Empty" },
...options.map((item) => ({
value: String(item.assetId),
label: item.asset.name,
})),
]}
/>
{options.length === 0 && (
<span className="text-xs text-muted-foreground">
No matching assets in your locker.

View file

@ -4,13 +4,7 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Loadout } from "@/payload-types";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Combobox } from "@/components/ui/combobox";
import { setLoadoutSlot, equipLoadout } from "@/app/(frontend)/locker/actions";
import {
getLoadoutSlot,
@ -77,22 +71,18 @@ export function LockerCharacterPanel({ loadouts, items }: LockerCharacterPanelPr
</div>
<div className="border-b border-border/50 p-2">
<Select
<Combobox
className="h-8 rounded-sm border-border/60 bg-background/40 text-xs uppercase tracking-wide"
value={equippedLoadout ? String(equippedLoadout.id) : ""}
onValueChange={changeLoadout}
disabled={busy || loadouts.length === 0}
>
<SelectTrigger className="h-8 rounded-sm border-border/60 bg-background/40 text-xs uppercase tracking-wide">
<SelectValue placeholder="SELECT LOADOUT" />
</SelectTrigger>
<SelectContent>
{loadouts.map((loadout) => (
<SelectItem key={loadout.id} value={String(loadout.id)}>
{loadout.name}
</SelectItem>
))}
</SelectContent>
</Select>
placeholder="SELECT LOADOUT"
searchPlaceholder="Search loadouts..."
options={loadouts.map((loadout) => ({
value: String(loadout.id),
label: loadout.name,
}))}
/>
</div>
<div className="p-2">

View file

@ -4,14 +4,8 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import type { Asset, Loadout } from "@/payload-types";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Wardrobe } from "./Wardrobe";
import { WardrobeSlotPicker } from "./WardrobeSlotPicker";
import {
@ -115,26 +109,20 @@ export function WardrobePanel({ userName, loadouts, equippedLoadout, items }: Wa
) : (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Select
<Combobox
className="flex-1"
value={equippedLoadout ? String(equippedLoadout.id) : ""}
onValueChange={(v) => handleEquip(Number(v))}
disabled={busy}
>
<SelectTrigger className="flex-1">
<SelectValue
placeholder={
equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"
}
/>
</SelectTrigger>
<SelectContent>
{loadouts.map((l) => (
<SelectItem key={l.id} value={String(l.id)}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
placeholder={
equippedLoadout ? equippedLoadout.name : "Select a loadout to equip"
}
searchPlaceholder="Search loadouts..."
options={loadouts.map((l) => ({
value: String(l.id),
label: l.name,
}))}
/>
{equippedLoadout && (
<Button
variant="outline"

View file

@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
@ -13,15 +14,13 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { placeStructure } from "@/app/(frontend)/logistics/map/actions";
import { validatePlacement, type NodeInput, type StructureInput, type ZoneInput } from "@/lib/map/placement";
import {
validatePlacement,
type NodeInput,
type StructureInput,
type ZoneInput,
} from "@/lib/map/placement";
import type { MapPoint } from "@/lib/map/points";
import type { MapConfig, NodeFeature, StructureFeature, ZoneFeature } from "@/lib/map/types";
@ -86,9 +85,18 @@ export function PlacementDialog({
terrainType?: string | null;
constructionDurationMinutes?: number | null;
requiredTech?: { name?: string }[] | number[];
materials?: { resource?: { id: number; name?: string } | number; amount?: number | null }[];
resourcesInRange?: { resource?: { id: number } | number | null; range?: number | null }[];
structuresInRange?: { structure?: { id: number } | number | null; range?: number | null }[];
materials?: {
resource?: { id: number; name?: string } | number;
amount?: number | null;
}[];
resourcesInRange?: {
resource?: { id: number } | number | null;
range?: number | null;
}[];
structuresInRange?: {
structure?: { id: number } | number | null;
range?: number | null;
}[];
}[];
};
setBlueprints(
@ -98,7 +106,9 @@ export function PlacementDialog({
terrainType: (doc.terrainType as "land" | "water" | null | undefined) ?? "land",
constructionDurationMinutes: doc.constructionDurationMinutes ?? null,
requiredTech: (doc.requiredTech ?? [])
.filter((tech): tech is { name?: string } => typeof tech === "object" && tech !== null)
.filter(
(tech): tech is { name?: string } => typeof tech === "object" && tech !== null,
)
.map((tech) => tech.name ?? "Unknown"),
materials: (doc.materials ?? []).map((material) => ({
resource:
@ -235,18 +245,18 @@ export function PlacementDialog({
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="place-blueprint">Blueprint</Label>
<Select value={blueprintId} onValueChange={setBlueprintId}>
<SelectTrigger id="place-blueprint" className="w-full">
<SelectValue placeholder="Select blueprint" />
</SelectTrigger>
<SelectContent>
{blueprints.map((option) => (
<SelectItem key={option.id} value={String(option.id)}>
{option.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
id="place-blueprint"
value={blueprintId}
onValueChange={setBlueprintId}
placeholder="Select blueprint"
searchPlaceholder="Search blueprints..."
emptyMessage="No blueprints available."
options={blueprints.map((option) => ({
value: String(option.id),
label: option.name,
}))}
/>
</div>
<div className="flex flex-col gap-1.5">
@ -261,19 +271,20 @@ export function PlacementDialog({
<div className="flex flex-col gap-1.5">
<Label htmlFor="place-faction">Faction (optional)</Label>
<Select value={factionId} onValueChange={setFactionId}>
<SelectTrigger id="place-faction" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No faction</SelectItem>
{factions.map((faction) => (
<SelectItem key={faction.id} value={String(faction.id)}>
{faction.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
id="place-faction"
value={factionId}
onValueChange={setFactionId}
searchPlaceholder="Search factions..."
emptyMessage="No factions available."
options={[
{ value: "none", label: "No faction" },
...factions.map((faction) => ({
value: String(faction.id),
label: faction.name,
})),
]}
/>
</div>
{blueprint && (

View file

@ -5,17 +5,11 @@ import { useRouter } from "next/navigation";
import type { Asset, LockerStorage } from "@/payload-types";
import { PlusIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Combobox } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
@ -196,28 +190,21 @@ export function CreateListingDialog({
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label>Item</Label>
<Select
<Combobox
value={assetId}
onValueChange={(v) => {
setAssetId(v);
setQuantity("1");
}}
>
<SelectTrigger>
<SelectValue placeholder="Select an item from your locker" />
</SelectTrigger>
<SelectContent>
{sellable.map((s) => (
<SelectItem
key={s.assetId}
value={String(s.assetId)}
disabled={!s.tradeable || !s.isLive}
>
{s.name} ({s.quantity.toLocaleString()}×)
</SelectItem>
))}
</SelectContent>
</Select>
placeholder="Select an item from your locker"
searchPlaceholder="Search items..."
emptyMessage="Nothing is approved for trading yet."
options={sellable.map((s) => ({
value: String(s.assetId),
label: `${s.name} (${s.quantity.toLocaleString()}×)`,
disabled: !s.tradeable || !s.isLive,
}))}
/>
{sellable.length === 0 && (
<p className="text-xs text-muted-foreground">
{hasLockerItems

View file

@ -10,6 +10,7 @@ import {
setPokerHoleQueue,
} from "@/app/(frontend)/minigames/poker/actions";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
@ -65,11 +66,7 @@ function cardLabel(card: Card): string {
* what lets a single pick land; the previous null-propagating version made
* first picks no-ops and hole-card queuing impossible.
*/
export function applyCardPick(
value: Card | null,
part: "rank" | "suit",
next: string,
): Card {
export function applyCardPick(value: Card | null, part: "rank" | "suit", next: string): Card {
if (part === "rank") {
return { rank: Number(next) as Rank, suit: value?.suit ?? "s" };
}
@ -91,18 +88,17 @@ function CardPicker({
return (
<div className="flex items-center gap-1.5">
<span className="w-10 text-xs text-muted-foreground">{label}</span>
<Select value={value ? String(value.rank) : ""} onValueChange={setRank}>
<SelectTrigger className="h-8 w-20 text-xs">
<SelectValue placeholder="Rank" />
</SelectTrigger>
<SelectContent>
{RANK_OPTIONS.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
className="h-8 w-20 text-xs"
value={value ? String(value.rank) : ""}
onValueChange={setRank}
placeholder="Rank"
searchPlaceholder="Rank..."
options={RANK_OPTIONS.map((option) => ({
value: String(option.value),
label: option.label,
}))}
/>
<Select value={value?.suit ?? ""} onValueChange={setSuit}>
<SelectTrigger className="h-8 w-24 text-xs">
<SelectValue placeholder="Suit" />
@ -258,20 +254,19 @@ export function PokerDevPanel({
<section className="flex flex-col gap-2 border border-border p-3">
<h3 className="text-xs font-semibold uppercase tracking-wider">Next hand hole cards</h3>
<Select value={seatId} onValueChange={setSeatId}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Player" />
</SelectTrigger>
<SelectContent>
{view.players
.filter((player) => !player.eliminated)
.map((player) => (
<SelectItem key={player.seatId} value={String(player.seatId)}>
{player.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
className="h-8 text-xs"
value={seatId}
onValueChange={setSeatId}
placeholder="Player"
searchPlaceholder="Search players..."
options={view.players
.filter((player) => !player.eliminated)
.map((player) => ({
value: String(player.seatId),
label: player.name,
}))}
/>
<div className="flex flex-col gap-1.5">
<CardPicker label="Card 1" value={holeA} onChange={setHoleA} />
<CardPicker label="Card 2" value={holeB} onChange={setHoleB} />
@ -289,20 +284,19 @@ export function PokerDevPanel({
<section className="flex flex-col gap-2 border border-border p-3">
<h3 className="text-xs font-semibold uppercase tracking-wider">Stack adjustment</h3>
<Select value={adjustSeat} onValueChange={setAdjustSeat}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Player" />
</SelectTrigger>
<SelectContent>
{view.players
.filter((player) => !player.eliminated)
.map((player) => (
<SelectItem key={player.seatId} value={String(player.seatId)}>
{player.name} ({player.stack.toLocaleString()})
</SelectItem>
))}
</SelectContent>
</Select>
<Combobox
className="h-8 text-xs"
value={adjustSeat}
onValueChange={setAdjustSeat}
placeholder="Player"
searchPlaceholder="Search players..."
options={view.players
.filter((player) => !player.eliminated)
.map((player) => ({
value: String(player.seatId),
label: `${player.name} (${player.stack.toLocaleString()})`,
}))}
/>
<div className="flex items-center gap-2">
<Input
type="number"

View file

@ -12,6 +12,7 @@ import {
} from "@/lib/evaluations/levels";
import { submitEvaluation } from "@/app/(frontend)/profile/[username]/actions";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
@ -134,20 +135,20 @@ export function LeadershipEvaluationDialog({
<form className="flex flex-col gap-4" onSubmit={handleSubmit} aria-busy={pending}>
<div className="flex flex-col gap-1.5">
<Label htmlFor="evaluation-mission">Mission</Label>
<Select value={missionId} onValueChange={setMissionId} disabled={pending}>
<SelectTrigger id="evaluation-mission" aria-invalid={error !== null}>
<SelectValue placeholder="Select a completed mission" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{missions.map((mission) => (
<SelectItem key={mission.id} value={String(mission.id)}>
{mission.name} · {formatMissionDate(mission.startDateTime)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Combobox
id="evaluation-mission"
aria-invalid={error !== null}
value={missionId}
onValueChange={setMissionId}
disabled={pending}
placeholder="Select a completed mission"
searchPlaceholder="Search missions..."
emptyMessage="No completed missions to evaluate."
options={missions.map((mission) => ({
value: String(mission.id),
label: `${mission.name} · ${formatMissionDate(mission.startDateTime)}`,
}))}
/>
</div>
<div className="flex flex-col gap-1.5">

View file

@ -3,6 +3,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import {
Dialog,
DialogContent,
@ -11,13 +12,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { promoteMember } from "@/app/(frontend)/profile/[username]/actions";
import { ArrowUpIcon, Loader2Icon, MedalIcon } from "lucide-react";
import { toast } from "sonner";
@ -97,21 +91,16 @@ export function PromoteButton({ username, currentRankName, targetRanks }: Promot
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Select
<Combobox
value={selectedRankId != null ? String(selectedRankId) : undefined}
onValueChange={(v) => setRankId(Number(v))}
>
<SelectTrigger>
<SelectValue placeholder="Select a rank" />
</SelectTrigger>
<SelectContent>
{targetRanks.map((r) => (
<SelectItem key={r.id} value={String(r.id)}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
placeholder="Select a rank"
searchPlaceholder="Search ranks..."
options={targetRanks.map((r) => ({
value: String(r.id),
label: r.name,
}))}
/>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
<DialogFooter>
@ -127,4 +116,4 @@ export function PromoteButton({ username, currentRankName, targetRanks }: Promot
</Dialog>
</section>
);
}
}

View file

@ -1,8 +1,11 @@
"use client";
import { useState } from "react";
import { TagsIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@ -11,15 +14,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { KanbanFiltersProps, SprintFilter } from "./KanbanBoard";
function sprintFilterValue(value: string): SprintFilter {
@ -29,7 +23,9 @@ function sprintFilterValue(value: string): SprintFilter {
}
function activeSprints(sprints: KanbanFiltersProps["sprints"]) {
const active = sprints.filter((sprint) => ["planned", "active", "in_progress"].includes(sprint.status));
const active = sprints.filter((sprint) =>
["planned", "active", "in_progress"].includes(sprint.status),
);
return active.length > 0 ? active : sprints;
}
@ -43,6 +39,12 @@ export function KanbanFilters({
}: KanbanFiltersProps) {
const selectedLabelCount = selectedLabelIds.length;
const sprintOptions = activeSprints(sprints);
const [labelQuery, setLabelQuery] = useState("");
const [labelsOpen, setLabelsOpen] = useState(false);
const labelNeedle = labelQuery.trim().toLowerCase();
const visibleLabels = labelNeedle
? labels.filter((label) => label.name.toLowerCase().includes(labelNeedle))
: labels;
const sprintLabel =
sprintFilter === "all"
? "All sprints"
@ -50,27 +52,23 @@ export function KanbanFilters({
return (
<div className="flex flex-wrap items-center gap-2">
<Select
<Combobox
value={String(sprintFilter)}
onValueChange={(value) => onSprintFilterChange(sprintFilterValue(value))}
>
<SelectTrigger className="w-[190px]" aria-label="Filter by sprint">
<SelectValue placeholder={sprintLabel ?? "Select sprint"} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Sprint</SelectLabel>
<SelectItem value="all">All sprints</SelectItem>
{sprintOptions.map((sprint) => (
<SelectItem key={sprint.id} value={String(sprint.id)}>
{sprint.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
className="w-[190px]"
aria-label="Filter by sprint"
placeholder={sprintLabel ?? "Select sprint"}
searchPlaceholder="Search sprints..."
options={[
{ value: "all", label: "All sprints" },
...sprintOptions.map((sprint) => ({
value: String(sprint.id),
label: sprint.name,
})),
]}
/>
<DropdownMenu>
<DropdownMenu open={labelsOpen} onOpenChange={setLabelsOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" aria-label="Filter by label">
<TagsIcon data-icon="inline-start" />
@ -81,10 +79,22 @@ export function KanbanFilters({
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>Filter by label</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-2 pb-1.5">
<Input
value={labelQuery}
onChange={(event) => setLabelQuery(event.target.value)}
placeholder="Search labels..."
className="h-7 text-xs"
/>
</div>
{labels.length === 0 ? (
<span className="block px-2 py-1.5 text-sm text-muted-foreground">No labels yet</span>
) : visibleLabels.length === 0 ? (
<span className="block px-2 py-1.5 text-sm text-muted-foreground">
No matching labels
</span>
) : (
labels.map((label) => (
visibleLabels.map((label) => (
<DropdownMenuCheckboxItem
key={label.id}
checked={selectedLabelIds.includes(label.id)}

View file

@ -11,6 +11,7 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
interface LogEntry {
id: number;
@ -37,6 +38,8 @@ export function EventLedger({
const [loading, setLoading] = useState(true);
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
const [currentUserId, setCurrentUserId] = useState<number | null>(null);
const [typeQuery, setTypeQuery] = useState("");
const [filterMenuOpen, setFilterMenuOpen] = useState(false);
const initialDone = useRef(false);
const eventTypes = useMemo(() => {
@ -44,6 +47,11 @@ export function EventLedger({
return Array.from(set).sort();
}, [entries]);
const typeNeedle = typeQuery.trim().toLowerCase();
const visibleTypes = typeNeedle
? eventTypes.filter((t) => formatType(t).toLowerCase().includes(typeNeedle))
: eventTypes;
const filtered = useMemo(
() => (selectedTypes.size === 0 ? entries : entries.filter((e) => selectedTypes.has(e.type))),
[entries, selectedTypes],
@ -138,7 +146,13 @@ export function EventLedger({
</ItemHeader>
<ItemContent>
{eventTypes.length > 1 && (
<DropdownMenu>
<DropdownMenu
open={filterMenuOpen}
onOpenChange={(next) => {
setFilterMenuOpen(next);
if (!next) setTypeQuery("");
}}
>
<span className="text-xs text-muted-foreground">Filter event logs...</span>
<DropdownMenuTrigger asChild>
<button
@ -148,22 +162,39 @@ export function EventLedger({
{dropdownLabel}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuContent align="start" className="w-60">
<div className="p-1.5">
<Input
value={typeQuery}
onChange={(event) => setTypeQuery(event.target.value)}
placeholder="Search event types..."
className="h-7 rounded-none text-xs"
/>
</div>
<DropdownMenuCheckboxItem
checked={selectedTypes.size === 0}
onCheckedChange={() => setSelectedTypes(new Set())}
onSelect={(event) => event.preventDefault()}
>
All
</DropdownMenuCheckboxItem>
{eventTypes.map((t) => (
<DropdownMenuCheckboxItem
key={t}
checked={selectedTypes.has(t)}
onCheckedChange={() => toggleType(t)}
>
{formatType(t)}
</DropdownMenuCheckboxItem>
))}
<div className="max-h-64 overflow-y-auto">
{visibleTypes.map((t) => (
<DropdownMenuCheckboxItem
key={t}
checked={selectedTypes.has(t)}
onCheckedChange={() => toggleType(t)}
onSelect={(event) => event.preventDefault()}
>
{formatType(t)}
</DropdownMenuCheckboxItem>
))}
{visibleTypes.length === 0 && (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
No matches
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}

View file

@ -9,6 +9,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Combobox } from "@/components/ui/combobox";
import {
Select,
SelectContent,
@ -508,21 +509,20 @@ export function ManageStorageDialog({
<div className="flex flex-col gap-2">
<Label>Transport Vehicle</Label>
{availableVehicles.length > 0 ? (
<Select value={shipmentVehicleId} onValueChange={setShipmentVehicleId}>
<SelectTrigger>
<SelectValue placeholder="Select a vehicle..." />
</SelectTrigger>
<SelectContent>
{availableVehicles.map((v) => {
const vType = typeof v.type === "object" ? v.type : null;
return (
<SelectItem key={v.id} value={String(v.id)}>
{v.name} ({vType?.name ?? "Unknown"}), {v.currentFuel?.toFixed(0)} fuel
</SelectItem>
);
})}
</SelectContent>
</Select>
<Combobox
value={shipmentVehicleId}
onValueChange={setShipmentVehicleId}
placeholder="Select a vehicle..."
searchPlaceholder="Search vehicles..."
emptyMessage="No vehicles with range."
options={availableVehicles.map((v) => {
const vType = typeof v.type === "object" ? v.type : null;
return {
value: String(v.id),
label: `${v.name} (${vType?.name ?? "Unknown"}), ${v.currentFuel?.toFixed(0)} fuel`,
};
})}
/>
) : (
<div className="flex items-center gap-2 p-3 rounded-md border border-amber-500/30 bg-amber-500/5 text-sm text-amber-400">
<TruckIcon className="size-4 shrink-0" />
@ -684,7 +684,7 @@ export function ManageStorageDialog({
<TabsContent value="deposit" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select
<Combobox
value={depositResourceId}
onValueChange={(val) => {
setDepositResourceId(val);
@ -692,29 +692,20 @@ export function ManageStorageDialog({
clampAmount(depositAmount, getDepositLimitInfoFor(val)?.max),
);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{allResources
.filter((r) => isResourceAllowed(r.id))
.map((resource) => {
const cap = getAllowedDepositCap(resource.id);
return (
<SelectItem key={resource.id} value={String(resource.id)}>
{resource.name}
{cap !== undefined && (
<span className="text-muted-foreground">
{" "}
({cap.toLocaleString()} max)
</span>
)}
</SelectItem>
);
})}
</SelectContent>
</Select>
placeholder="Select a resource..."
searchPlaceholder="Search resources..."
emptyMessage="Nothing may be deposited here."
options={allResources
.filter((r) => isResourceAllowed(r.id))
.map((resource) => {
const cap = getAllowedDepositCap(resource.id);
return {
value: String(resource.id),
label: resource.name,
hint: cap !== undefined ? `${cap.toLocaleString()} max` : undefined,
};
})}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
@ -754,30 +745,27 @@ export function ManageStorageDialog({
<TabsContent value="withdraw" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select
<Combobox
value={withdrawResourceId}
onValueChange={(val) => {
setWithdrawResourceId(val);
const stored = findStoredResource(val);
setWithdrawAmount(clampAmount(withdrawAmount, stored?.amount));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{storedResources.map((entry) => {
placeholder="Select a resource..."
searchPlaceholder="Search resources..."
emptyMessage="Nothing stored yet."
options={storedResources
.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()}{" "}
{pluralize(entry.amount, resource.unitOfMeasure)})
</SelectItem>
);
})}
</SelectContent>
</Select>
return {
value: String(resource.id),
label: `${resource.name} (${entry.amount.toLocaleString()} ${pluralize(entry.amount, resource.unitOfMeasure)})`,
};
})
.filter((option) => option !== null)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>
@ -817,51 +805,43 @@ export function ManageStorageDialog({
<TabsContent value="transfer" className="flex flex-col gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label>Destination</Label>
<Select
<Combobox
value={transferToId}
onValueChange={(val) => {
setTransferToId(val);
setTransferAmount(clampAmount(transferAmount, getTransferMaxFor(val)));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a structure..." />
</SelectTrigger>
<SelectContent>
{allStructures.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
placeholder="Select a structure..."
searchPlaceholder="Search structures..."
options={allStructures.map((s) => ({
value: String(s.id),
label: s.name,
}))}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Resource</Label>
<Select
<Combobox
value={transferResourceId}
onValueChange={(val) => {
setTransferResourceId(val);
const stored = findStoredResource(val);
setTransferAmount(clampAmount(transferAmount, stored?.amount));
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a resource..." />
</SelectTrigger>
<SelectContent>
{storedResources.map((entry) => {
placeholder="Select a resource..."
searchPlaceholder="Search resources..."
emptyMessage="Nothing stored yet."
options={storedResources
.map((entry) => {
const resource = entry.resource as Resource;
if (!resource || typeof resource === "number") return null;
return (
<SelectItem key={entry.id ?? resource.id} value={String(resource.id)}>
{resource.name} ({entry.amount.toLocaleString()}{" "}
{pluralize(entry.amount, resource.unitOfMeasure)})
</SelectItem>
);
})}
</SelectContent>
</Select>
return {
value: String(resource.id),
label: `${resource.name} (${entry.amount.toLocaleString()} ${pluralize(entry.amount, resource.unitOfMeasure)})`,
};
})
.filter((option) => option !== null)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Amount</Label>

View file

@ -0,0 +1,122 @@
"use client";
import * as React from "react";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
export interface ComboboxOption {
value: string;
label: string;
/** Optional secondary line rendered under the label. */
hint?: string;
/** Disabled options render muted and cannot be selected. */
disabled?: boolean;
}
interface ComboboxProps extends React.ComponentPropsWithoutRef<"button"> {
value?: string;
onValueChange: (value: string) => void;
options: ComboboxOption[];
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
/** Trigger sizing classes (the dropdown keeps the trigger's width). */
className?: string;
/** Popover content classes. */
contentClassName?: string;
}
/**
* Searchable single-value dropdown (Popover + cmdk), a drop-in replacement for
* ui/select wherever the item list is data-driven and can grow long. The
* popover stays as wide as the trigger and the list scrolls at 16rem.
*/
function Combobox({
value,
onValueChange,
options,
placeholder = "Select...",
searchPlaceholder = "Search...",
emptyMessage = "No matches.",
disabled = false,
className,
contentClassName,
...rest
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const selected = options.find((option) => option.value === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
disabled={disabled}
role="combobox"
aria-expanded={open}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
"disabled:cursor-not-allowed disabled:opacity-50",
!selected && "text-muted-foreground",
className,
)}
{...rest}
>
<span className="truncate">{selected ? selected.label : placeholder}</span>
<ChevronDown className="size-4 shrink-0 opacity-50" aria-hidden />
</PopoverTrigger>
<PopoverContent
className={cn("w-[var(--radix-popover-trigger-width)] p-0", contentClassName)}
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList className="max-h-64">
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={`${option.label} ${option.value}`}
disabled={option.disabled}
onSelect={() => {
onValueChange(option.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-1 size-4",
option.value === value ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{option.hint && (
<span className="block truncate text-xs text-muted-foreground">
{option.hint}
</span>
)}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export { Combobox };