mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-25 16:03:31 +00:00
feat: add themed select menu
This commit is contained in:
@@ -2,6 +2,12 @@ import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { searchAdminAuditLogs } from "@/lib/api/client/app/admin/audit";
|
||||
import type { AdminAuditLog, AdminAuditLogSearch } from "@/lib/api/models/app/admin/Audit";
|
||||
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
|
||||
|
||||
const LIMIT_OPTIONS: SelectOption[] = [25, 50, 100].map((n) => ({
|
||||
value: String(n),
|
||||
label: String(n),
|
||||
}));
|
||||
|
||||
// Free-form action / target_type strings used by handlers across the app.
|
||||
// These are populated dynamically from the result set too — anything new the
|
||||
@@ -87,6 +93,22 @@ export default function AdminAuditPage() {
|
||||
return Array.from(set).sort();
|
||||
}, [data]);
|
||||
|
||||
const actionOptions = useMemo<SelectOption[]>(
|
||||
() => [
|
||||
{ value: "", label: "(any)" },
|
||||
...allActions.map((a) => ({ value: a, label: a })),
|
||||
],
|
||||
[allActions],
|
||||
);
|
||||
|
||||
const entityTypeOptions = useMemo<SelectOption[]>(
|
||||
() => [
|
||||
{ value: "", label: "(any)" },
|
||||
...allEntityTypes.map((t) => ({ value: t, label: t })),
|
||||
],
|
||||
[allEntityTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -120,28 +142,22 @@ export default function AdminAuditPage() {
|
||||
<div className="border rounded-lg p-3 mb-3 bg-slate-50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Field label="Action">
|
||||
<select
|
||||
<SelectMenu
|
||||
value={filters.action ?? ""}
|
||||
onChange={(e) => applyFilter({ action: e.target.value || undefined })}
|
||||
className={inp}
|
||||
>
|
||||
<option value="">(any)</option>
|
||||
{allActions.map((a) => (
|
||||
<option key={a} value={a}>{a}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => applyFilter({ action: v || undefined })}
|
||||
options={actionOptions}
|
||||
className="w-full"
|
||||
aria-label="Action"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Target type">
|
||||
<select
|
||||
<SelectMenu
|
||||
value={filters.target_type ?? ""}
|
||||
onChange={(e) => applyFilter({ target_type: e.target.value || undefined })}
|
||||
className={inp}
|
||||
>
|
||||
<option value="">(any)</option>
|
||||
{allEntityTypes.map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => applyFilter({ target_type: v || undefined })}
|
||||
options={entityTypeOptions}
|
||||
className="w-full"
|
||||
aria-label="Target type"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Target ID">
|
||||
<input
|
||||
@@ -177,15 +193,15 @@ export default function AdminAuditPage() {
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<div className="text-xs text-slate-500">
|
||||
Limit:{" "}
|
||||
<select
|
||||
value={filters.limit ?? 50}
|
||||
onChange={(e) => applyFilter({ limit: parseInt(e.target.value, 10) })}
|
||||
className="border rounded px-2 py-1 text-xs"
|
||||
>
|
||||
{[25, 50, 100].map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
Limit:
|
||||
<SelectMenu
|
||||
value={String(filters.limit ?? 50)}
|
||||
onChange={(v) => applyFilter({ limit: parseInt(v, 10) })}
|
||||
options={LIMIT_OPTIONS}
|
||||
minWidth={80}
|
||||
aria-label="Result limit"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
|
||||
import {
|
||||
applyProfileToAll,
|
||||
checkReleases,
|
||||
@@ -23,6 +24,11 @@ import type {
|
||||
|
||||
type Tab = "aws" | "profiles";
|
||||
|
||||
const APP_ENV_OPTIONS: SelectOption[] = [
|
||||
{ value: "prod", label: "prod" },
|
||||
{ value: "dev", label: "dev" },
|
||||
];
|
||||
|
||||
export default function AdminCredentialsPage() {
|
||||
const [tab, setTab] = useState<Tab>("aws");
|
||||
|
||||
@@ -414,6 +420,14 @@ function ProfileForm({
|
||||
const [channel, setChannel] = useState<"pinned" | "stable" | "dev">(initial?.release_channel ?? "pinned");
|
||||
const [autoUpdate, setAutoUpdate] = useState<boolean>(initial?.auto_update ?? false);
|
||||
|
||||
const awsCredentialOptions = useMemo<SelectOption[]>(
|
||||
() => [
|
||||
{ value: "", label: "(none)" },
|
||||
...awsOptions.map((a) => ({ value: a.id, label: `${a.name} (${a.region})` })),
|
||||
],
|
||||
[awsOptions],
|
||||
);
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = initial
|
||||
@@ -438,10 +452,12 @@ function ProfileForm({
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="App env">
|
||||
<select value={form.app_env} onChange={(e) => setForm({ ...form, app_env: e.target.value })} className={inp}>
|
||||
<option value="prod">prod</option>
|
||||
<option value="dev">dev</option>
|
||||
</select>
|
||||
<SelectMenu
|
||||
value={form.app_env ?? ""}
|
||||
onChange={(v) => setForm({ ...form, app_env: v })}
|
||||
options={APP_ENV_OPTIONS}
|
||||
className="w-full"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Worker image">
|
||||
<input value={form.worker_image} onChange={(e) => setForm({ ...form, worker_image: e.target.value })} className={inp} />
|
||||
@@ -449,16 +465,12 @@ function ProfileForm({
|
||||
</div>
|
||||
|
||||
<Field label="AWS credentials">
|
||||
<select
|
||||
<SelectMenu
|
||||
value={form.aws_credential_id ?? ""}
|
||||
onChange={(e) => setForm({ ...form, aws_credential_id: e.target.value || null })}
|
||||
className={inp}
|
||||
>
|
||||
<option value="">(none)</option>
|
||||
{awsOptions.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.name} ({a.region})</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, aws_credential_id: v || null })}
|
||||
options={awsCredentialOptions}
|
||||
className="w-full"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<h4 className="font-semibold text-slate-600 text-sm mt-4 mb-1">Kafka</h4>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
|
||||
import {
|
||||
convertWorkerToDedicated,
|
||||
deleteWorker,
|
||||
@@ -81,6 +82,16 @@ export default function AdminWorkerDetailPage() {
|
||||
});
|
||||
|
||||
const profiles = useQuery({ queryKey: ["admin", "profiles"], queryFn: listWorkerProfiles });
|
||||
const profileOptions: SelectOption[] = useMemo(
|
||||
() => [
|
||||
{ value: "", label: "(use backend env defaults)" },
|
||||
...(profiles.data?.data?.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} · ${p.app_env}`,
|
||||
})) ?? []),
|
||||
],
|
||||
[profiles.data],
|
||||
);
|
||||
const assignProfile = useMutation({
|
||||
mutationFn: (profileID: string | null) => assignWorkerProfile(id, profileID),
|
||||
...opts("assign profile"),
|
||||
@@ -261,19 +272,12 @@ export default function AdminWorkerDetailPage() {
|
||||
|
||||
<Section title="Worker profile">
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
<SelectMenu
|
||||
value={w.profile_id ?? ""}
|
||||
onChange={(e) => assignProfile.mutate(e.target.value || null)}
|
||||
onChange={(v) => assignProfile.mutate(v || null)}
|
||||
options={profileOptions}
|
||||
disabled={assignProfile.isPending}
|
||||
className="border rounded px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">(use backend env defaults)</option>
|
||||
{profiles.data?.data?.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} · {p.app_env}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
/>
|
||||
{w.profile_id && (
|
||||
<Btn onClick={() => apply.mutate()} disabled={apply.isPending} primary>
|
||||
Apply config & restart
|
||||
@@ -389,20 +393,21 @@ export default function AdminWorkerDetailPage() {
|
||||
.filter((other) => other.worker_type === w.worker_type && other.free_tier === w.free_tier)
|
||||
.filter((other) => other.install_state === "installed")
|
||||
.sort((a, b) => a.account_count - b.account_count);
|
||||
const rewireOptions: SelectOption[] = [
|
||||
{ value: "", label: "— pick a target worker —" },
|
||||
...candidates.map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.name || c.id.slice(0, 8)} · ${c.account_count} accounts · ${c.ssh_host || c.ip_addr}`,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
<SelectMenu
|
||||
value={rewireTarget}
|
||||
onChange={(e) => setRewireTarget(e.target.value)}
|
||||
className="border rounded px-3 py-1.5 text-sm w-full sm:min-w-[18rem]"
|
||||
>
|
||||
<option value="">— pick a target worker —</option>
|
||||
{candidates.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name || c.id.slice(0, 8)} · {c.account_count} accounts · {c.ssh_host || c.ip_addr}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setRewireTarget(v)}
|
||||
options={rewireOptions}
|
||||
className="w-full sm:min-w-[18rem]"
|
||||
/>
|
||||
<Btn onClick={rewireAll} disabled={!rewireTarget} primary>
|
||||
Move all {w.account_count} account{w.account_count === 1 ? "" : "s"}
|
||||
</Btn>
|
||||
@@ -454,26 +459,26 @@ export default function AdminWorkerDetailPage() {
|
||||
: "Drain target (not needed — this worker is empty)"
|
||||
}
|
||||
>
|
||||
<select
|
||||
<SelectMenu
|
||||
value={convertDrain}
|
||||
onChange={(e) => setConvertDrain(e.target.value)}
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">— pick a target —</option>
|
||||
{(allWorkers.data?.data ?? [])
|
||||
.filter((other) =>
|
||||
other.id !== id &&
|
||||
other.worker_type === "shared" &&
|
||||
other.install_state === "installed",
|
||||
)
|
||||
.sort((a, b) => a.account_count - b.account_count)
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name || c.id.slice(0, 8)} · {c.account_count} accounts ·{" "}
|
||||
{c.free_tier ? "free" : "premium"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setConvertDrain(v)}
|
||||
options={[
|
||||
{ value: "", label: "— pick a target —" },
|
||||
...(allWorkers.data?.data ?? [])
|
||||
.filter(
|
||||
(other) =>
|
||||
other.id !== id &&
|
||||
other.worker_type === "shared" &&
|
||||
other.install_state === "installed",
|
||||
)
|
||||
.sort((a, b) => a.account_count - b.account_count)
|
||||
.map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.name || c.id.slice(0, 8)} · ${c.account_count} accounts · ${c.free_tier ? "free" : "premium"}`,
|
||||
})),
|
||||
]}
|
||||
className="w-full"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Btn onClick={doConvertToDedicated} disabled={converting} primary>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// from the purpose so the admin doesn't have to think about risk_pool and
|
||||
// worker_type independently from "what is this worker for."
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "@/lib/api/client/app/admin/workers";
|
||||
import { assignWorkerProfile, listWorkerProfiles } from "@/lib/api/client/app/admin/credentials";
|
||||
import type { CreateWorkerResponse } from "@/lib/api/models/app/admin/Worker";
|
||||
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
|
||||
import { API_URL } from "@/lib/information";
|
||||
|
||||
type Purpose = "shared" | "dedicated" | "risky";
|
||||
@@ -81,6 +82,17 @@ export default function AdminAddWorkerWizard() {
|
||||
const profiles = useQuery({ queryKey: ["admin", "profiles"], queryFn: listWorkerProfiles });
|
||||
const allWorkers = useQuery({ queryKey: ["admin", "workers", "managed"], queryFn: listManagedWorkers });
|
||||
|
||||
const profileOptions = useMemo<SelectOption[]>(
|
||||
() => [
|
||||
{ value: "", label: "(use backend env defaults)" },
|
||||
...(profiles.data?.data?.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} · ${p.app_env}`,
|
||||
})) ?? []),
|
||||
],
|
||||
[profiles.data?.data],
|
||||
);
|
||||
|
||||
// Steps shown depending on purpose. Dedicated adds an Owner step.
|
||||
const totalSteps = state.purpose === "dedicated" ? 5 : 4;
|
||||
|
||||
@@ -358,18 +370,13 @@ export default function AdminAddWorkerWizard() {
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className={lbl}>Worker profile</label>
|
||||
<select
|
||||
<SelectMenu
|
||||
value={state.profile_id}
|
||||
onChange={(e) => setState((s) => ({ ...s, profile_id: e.target.value }))}
|
||||
className={inp}
|
||||
>
|
||||
<option value="">(use backend env defaults)</option>
|
||||
{profiles.data?.data?.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} · {p.app_env}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setState((s) => ({ ...s, profile_id: v }))}
|
||||
options={profileOptions}
|
||||
placeholder="(use backend env defaults)"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-slate-400 text-xs mt-1">
|
||||
Supplies Kafka / Schema Registry / Redis / AWS keys at install time.
|
||||
</p>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Label, TextInput } from "@/components/ui/field";
|
||||
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
|
||||
import { useConfirm } from "@/hooks/context/confirm";
|
||||
import useConnectionDetail from "@/lib/api/hooks/app/integrations/useConnectionDetail";
|
||||
import useDisconnectIntegration from "@/lib/api/hooks/app/integrations/useDisconnectIntegration";
|
||||
@@ -342,6 +343,10 @@ function AddAutomation({
|
||||
const [eventType, setEventType] = React.useState(
|
||||
availableEvents.includes(REPLY_EVENT) ? REPLY_EVENT : availableEvents[0] ?? REPLY_EVENT,
|
||||
);
|
||||
const eventOptions = React.useMemo<SelectOption[]>(
|
||||
() => availableEvents.map((ev) => ({ value: ev, label: EVENT_LABELS[ev] ?? ev })),
|
||||
[availableEvents],
|
||||
);
|
||||
const [dest, setDest] = React.useState("");
|
||||
const [intents, setIntents] = React.useState<string[]>([]);
|
||||
const [minConf, setMinConf] = React.useState(0);
|
||||
@@ -375,17 +380,13 @@ function AddAutomation({
|
||||
<div className="rounded-md border border-sky-200 bg-sky-50/40 p-3 space-y-3">
|
||||
<div>
|
||||
<Label>When this happens</Label>
|
||||
<select
|
||||
<SelectMenu
|
||||
value={eventType}
|
||||
onChange={(e) => setEventType(e.target.value)}
|
||||
className="w-full h-7 px-2 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
|
||||
>
|
||||
{availableEvents.map((ev) => (
|
||||
<option key={ev} value={ev}>
|
||||
{EVENT_LABELS[ev] ?? ev}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setEventType}
|
||||
options={eventOptions}
|
||||
className="w-full"
|
||||
aria-label="When this happens"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reply-only filters */}
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
// effective limit going up on the org; rejection surfaces with the
|
||||
// admin's notes attached to the row.
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
|
||||
import { Section, SectionShell } from "../_components/SectionShell";
|
||||
import getCurrentOrganization from "@/lib/api/client/app/organizations/getCurrentOrganization";
|
||||
import listLimitRequests from "@/lib/api/client/app/organizations/listLimitRequests";
|
||||
@@ -51,6 +52,11 @@ export default function LimitsSettingsPage() {
|
||||
const [requested, setRequested] = useState<string>("");
|
||||
const [reason, setReason] = useState<string>("");
|
||||
|
||||
const fieldSelectOptions = useMemo<SelectOption[]>(
|
||||
() => FIELD_OPTIONS.map((opt) => ({ value: opt.value, label: opt.label })),
|
||||
[],
|
||||
);
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: () =>
|
||||
submitLimitRequest(orgId!, {
|
||||
@@ -108,17 +114,13 @@ export default function LimitsSettingsPage() {
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label className="text-[12px] font-medium text-slate-700">Resource</label>
|
||||
<select
|
||||
<SelectMenu
|
||||
value={field}
|
||||
onChange={(e) => setField(e.target.value as LimitField)}
|
||||
className="mt-1 block w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm"
|
||||
>
|
||||
{FIELD_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setField(v as LimitField)}
|
||||
options={fieldSelectOptions}
|
||||
className="mt-1 w-full"
|
||||
aria-label="Resource"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-500 mt-1">
|
||||
{FIELD_OPTIONS.find((o) => o.value === field)?.hint}
|
||||
</p>
|
||||
|
||||
@@ -27,6 +27,7 @@ import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import useCreateCampaign from "@/lib/api/hooks/app/campaigns/useCreateCampaign";
|
||||
import { Label, TextInput } from "@/components/ui/field";
|
||||
import { SelectMenu, type SelectOption } from "@/components/ui/select-menu";
|
||||
import WeekdayBitmask from "@/components/app/campaigns/schedule/WeekdayBitmask";
|
||||
import TagSelector from "@/components/app/popup/select/TagSelector";
|
||||
import { useUserProfile } from "@/hooks/context/user";
|
||||
@@ -449,21 +450,26 @@ function ScheduleStep({
|
||||
setEndTime: (v: string) => void;
|
||||
}) {
|
||||
const profile = useUserProfile();
|
||||
const timezoneOptions = React.useMemo<SelectOption[]>(
|
||||
() =>
|
||||
(profile?.timezones || []).map((tz) => ({
|
||||
value: tz.name,
|
||||
label: tz.display_name,
|
||||
})),
|
||||
[profile?.timezones],
|
||||
);
|
||||
return (
|
||||
<div className="space-y-5 max-w-[560px]">
|
||||
<div>
|
||||
<Label>Sending timezone</Label>
|
||||
<select
|
||||
<SelectMenu
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
className="w-full h-7 px-2 rounded-md border border-slate-200 bg-white text-[12.5px] text-slate-900 outline-none focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
|
||||
>
|
||||
{(profile?.timezones || []).map((tz) => (
|
||||
<option key={tz.name} value={tz.name}>
|
||||
{tz.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setTimezone(v)}
|
||||
options={timezoneOptions}
|
||||
className="w-full"
|
||||
placeholder="Select a timezone"
|
||||
aria-label="Sending timezone"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
Sends are scheduled in this zone. Worker IPs spread distribution naturally.
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// SelectMenu — the app's themed dropdown, replacing native <select> everywhere.
|
||||
// Built on the shared PopoverMenu primitives so it matches every other picker
|
||||
// (slate border, sky accent, rounded-md, h-7 density) and never shows the OS
|
||||
// dropdown chrome. Supports optional option groups (rendered as section labels)
|
||||
// while preserving declared order.
|
||||
//
|
||||
// (Distinct from the unused shadcn/Radix `select.tsx`, which has a different
|
||||
// theme + compound API.)
|
||||
|
||||
import React from "react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import {
|
||||
PopoverMenu,
|
||||
PopoverMenuTrigger,
|
||||
PopoverMenuContent,
|
||||
PopoverMenuItem,
|
||||
} from "@/components/ui/popover-menu";
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Optional section header. Consecutive options sharing a group are grouped. */
|
||||
group?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function SelectMenu({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "Select…",
|
||||
className,
|
||||
minWidth = 220,
|
||||
align = "start",
|
||||
disabled,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
minWidth?: number;
|
||||
align?: "start" | "end" | "center";
|
||||
disabled?: boolean;
|
||||
"aria-label"?: string;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const current = options.find((o) => o.value === value);
|
||||
|
||||
// Group consecutive options that share a `group`, preserving order.
|
||||
const groups: { group?: string; items: SelectOption[] }[] = [];
|
||||
for (const o of options) {
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.group === o.group) last.items.push(o);
|
||||
else groups.push({ group: o.group, items: [o] });
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverMenu open={open} onOpenChange={setOpen} align={align}>
|
||||
<PopoverMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
className={`h-7 px-2.5 rounded-md border border-slate-200 hover:border-slate-300 bg-white text-[12px] text-slate-700 inline-flex items-center gap-1.5 transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${className ?? ""}`}
|
||||
>
|
||||
<span className={`truncate flex-1 text-left ${current ? "" : "text-slate-400"}`}>
|
||||
{current?.label ?? placeholder}
|
||||
</span>
|
||||
<ChevronDownIcon className="w-3 h-3 text-slate-400 shrink-0" />
|
||||
</button>
|
||||
</PopoverMenuTrigger>
|
||||
<PopoverMenuContent minWidth={minWidth} className="max-h-72 overflow-y-auto">
|
||||
{groups.map((grp, gi) => (
|
||||
<React.Fragment key={gi}>
|
||||
{grp.group && (
|
||||
<div className="px-2 pt-1.5 pb-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-slate-400">
|
||||
{grp.group}
|
||||
</div>
|
||||
)}
|
||||
{grp.items.map((o) => (
|
||||
<PopoverMenuItem
|
||||
key={o.value}
|
||||
disabled={o.disabled}
|
||||
selected={o.value === value}
|
||||
onSelect={() => onChange(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</PopoverMenuItem>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</PopoverMenuContent>
|
||||
</PopoverMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectMenu;
|
||||
Reference in New Issue
Block a user