FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
85 lines
3.2 KiB
TypeScript
85 lines
3.2 KiB
TypeScript
"use client";
|
|
|
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
|
import clsx from "clsx";
|
|
import { Check, ChevronDown } from "lucide-react";
|
|
|
|
export interface SelectOption {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
|
|
// Radix disallows an empty-string Item value (it's reserved to mean "no
|
|
// selection" internally) - but several call sites here use "" to mean "all"
|
|
// / "no filter". This sentinel maps "" <-> a real string at the Radix
|
|
// boundary only, so callers can keep using "" as usual.
|
|
const EMPTY_VALUE_SENTINEL = "__select-empty__";
|
|
|
|
/**
|
|
* Custom dropdown replacing native <select> everywhere in the app. Native
|
|
* select option lists are rendered by the OS/browser itself and cannot be
|
|
* animated with CSS/JS in any browser - this is a real component (Radix's
|
|
* accessible, keyboard-navigable primitive) so the open/close transition
|
|
* (see select-in/select-out in tailwind.config.ts) actually applies.
|
|
*/
|
|
export function Select({
|
|
value,
|
|
onValueChange,
|
|
options,
|
|
placeholder,
|
|
name,
|
|
ariaLabel,
|
|
triggerClassName,
|
|
}: {
|
|
value: string;
|
|
onValueChange: (value: string) => void;
|
|
options: SelectOption[];
|
|
placeholder?: string;
|
|
name?: string;
|
|
ariaLabel?: string;
|
|
triggerClassName?: string;
|
|
}) {
|
|
return (
|
|
<SelectPrimitive.Root
|
|
value={value === "" ? EMPTY_VALUE_SENTINEL : value}
|
|
onValueChange={(v) => onValueChange(v === EMPTY_VALUE_SENTINEL ? "" : v)}
|
|
name={name}
|
|
>
|
|
<SelectPrimitive.Trigger
|
|
aria-label={ariaLabel}
|
|
className={clsx(
|
|
"focus-ring flex items-center justify-between gap-2 rounded-md border border-slate-300 bg-white text-left shadow-sm outline-none data-[placeholder]:text-slate-400",
|
|
triggerClassName ?? "px-3 py-2 text-sm",
|
|
)}
|
|
>
|
|
<SelectPrimitive.Value placeholder={placeholder} />
|
|
<SelectPrimitive.Icon>
|
|
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400" aria-hidden />
|
|
</SelectPrimitive.Icon>
|
|
</SelectPrimitive.Trigger>
|
|
<SelectPrimitive.Portal>
|
|
<SelectPrimitive.Content
|
|
position="popper"
|
|
sideOffset={4}
|
|
className="z-50 overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg data-[state=closed]:animate-select-out data-[state=open]:animate-select-in"
|
|
>
|
|
<SelectPrimitive.Viewport className="p-1">
|
|
{options.map((option) => (
|
|
<SelectPrimitive.Item
|
|
key={option.value}
|
|
value={option.value === "" ? EMPTY_VALUE_SENTINEL : option.value}
|
|
className="relative flex cursor-pointer select-none items-center rounded px-2 py-1.5 pl-7 text-sm text-slate-700 outline-none data-[highlighted]:bg-brand-50 data-[highlighted]:text-brand-700"
|
|
>
|
|
<SelectPrimitive.ItemIndicator className="absolute left-2 inline-flex items-center">
|
|
<Check className="h-3.5 w-3.5" aria-hidden />
|
|
</SelectPrimitive.ItemIndicator>
|
|
<SelectPrimitive.ItemText>{option.label}</SelectPrimitive.ItemText>
|
|
</SelectPrimitive.Item>
|
|
))}
|
|
</SelectPrimitive.Viewport>
|
|
</SelectPrimitive.Content>
|
|
</SelectPrimitive.Portal>
|
|
</SelectPrimitive.Root>
|
|
);
|
|
}
|