chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import {
|
||||
XMarkIcon,
|
||||
PlusIcon,
|
||||
CubeIcon,
|
||||
MagnifyingGlassIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { CheckboxIndicator } from "~/components/primitives/CheckboxIndicator";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
|
||||
const pillColors = [
|
||||
"bg-green-800/40 border-green-600/50",
|
||||
"bg-teal-800/40 border-teal-600/50",
|
||||
"bg-blue-800/40 border-blue-600/50",
|
||||
"bg-indigo-800/40 border-indigo-600/50",
|
||||
"bg-violet-800/40 border-violet-600/50",
|
||||
"bg-purple-800/40 border-purple-600/50",
|
||||
"bg-fuchsia-800/40 border-fuchsia-600/50",
|
||||
"bg-pink-800/40 border-pink-600/50",
|
||||
"bg-rose-800/40 border-rose-600/50",
|
||||
"bg-orange-800/40 border-orange-600/50",
|
||||
"bg-amber-800/40 border-amber-600/50",
|
||||
"bg-yellow-800/40 border-yellow-600/50",
|
||||
"bg-lime-800/40 border-lime-600/50",
|
||||
"bg-emerald-800/40 border-emerald-600/50",
|
||||
"bg-cyan-800/40 border-cyan-600/50",
|
||||
"bg-sky-800/40 border-sky-600/50",
|
||||
];
|
||||
|
||||
function getPillColor(value: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return pillColors[Math.abs(hash) % pillColors.length];
|
||||
}
|
||||
|
||||
export const TECHNOLOGY_OPTIONS = [
|
||||
"Airflow",
|
||||
"Angular",
|
||||
"Anthropic",
|
||||
"Astro",
|
||||
"Auth0",
|
||||
"AWS",
|
||||
"AWS SQS",
|
||||
"Azure",
|
||||
"BigQuery",
|
||||
"BullMQ",
|
||||
"Bun",
|
||||
"Cassandra",
|
||||
"Celery",
|
||||
"ClickHouse",
|
||||
"Clerk",
|
||||
"Cloudflare",
|
||||
"CockroachDB",
|
||||
"Cohere",
|
||||
"Convex",
|
||||
"Databricks",
|
||||
"Datadog",
|
||||
"DeepSeek",
|
||||
"Deno",
|
||||
"DigitalOcean",
|
||||
"Django",
|
||||
"Docker",
|
||||
"Drizzle",
|
||||
"DynamoDB",
|
||||
"Elasticsearch",
|
||||
"Electron",
|
||||
"Elevenlabs",
|
||||
"Expo",
|
||||
"Express",
|
||||
"FastAPI",
|
||||
"Fastify",
|
||||
"Firebase",
|
||||
"Flask",
|
||||
"Fly.io",
|
||||
"Gatsby",
|
||||
"GCP",
|
||||
"Go",
|
||||
"Google Cloud Tasks",
|
||||
"Google Gemini",
|
||||
"GraphQL",
|
||||
"Groq",
|
||||
"Heroku",
|
||||
"Hono",
|
||||
"htmx",
|
||||
"Hugging Face",
|
||||
"Inngest",
|
||||
"Kafka",
|
||||
"Kubernetes",
|
||||
"LangChain",
|
||||
"Laravel",
|
||||
"LlamaIndex",
|
||||
"MariaDB",
|
||||
"Midjourney",
|
||||
"Mistral",
|
||||
"MongoDB",
|
||||
"Mongoose",
|
||||
"MySQL",
|
||||
"Neo4j",
|
||||
"Neon",
|
||||
"Nest.js",
|
||||
"Netlify",
|
||||
"Next.js",
|
||||
"Node.js",
|
||||
"Nuxt",
|
||||
"Ollama",
|
||||
"OpenAI",
|
||||
"Perplexity",
|
||||
"PHP",
|
||||
"Pinecone",
|
||||
"PlanetScale",
|
||||
"Python",
|
||||
"PostHog",
|
||||
"PostgreSQL",
|
||||
"Prisma",
|
||||
"Pulumi",
|
||||
"RabbitMQ",
|
||||
"Railway",
|
||||
"React",
|
||||
"React Native",
|
||||
"Redis",
|
||||
"Redshift",
|
||||
"Remix",
|
||||
"Render",
|
||||
"Replicate",
|
||||
"Resend",
|
||||
"Ruby on Rails",
|
||||
"Rust",
|
||||
"SendGrid",
|
||||
"Sentry",
|
||||
"Sidekiq",
|
||||
"Snowflake",
|
||||
"Solid.js",
|
||||
"Spring Boot",
|
||||
"SQLite",
|
||||
"Stability AI",
|
||||
"Stripe",
|
||||
"Supabase",
|
||||
"Svelte",
|
||||
"SvelteKit",
|
||||
"Tailwind CSS",
|
||||
"Temporal",
|
||||
"Terraform",
|
||||
"Together AI",
|
||||
"tRPC",
|
||||
"Turso",
|
||||
"Twilio",
|
||||
"TypeORM",
|
||||
"Upstash",
|
||||
"Vercel",
|
||||
"Vercel AI SDK",
|
||||
"Vite",
|
||||
"Vue",
|
||||
"Weaviate",
|
||||
] as const;
|
||||
|
||||
type TechnologyPickerProps = {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
customValues: string[];
|
||||
onCustomValuesChange: (values: string[]) => void;
|
||||
};
|
||||
|
||||
export function TechnologyPicker({
|
||||
value,
|
||||
onChange,
|
||||
customValues,
|
||||
onCustomValuesChange,
|
||||
}: TechnologyPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [otherInputValue, setOtherInputValue] = useState("");
|
||||
const [showOtherInput, setShowOtherInput] = useState(false);
|
||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const allSelected = useMemo(() => [...value, ...customValues], [value, customValues]);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchValue) return TECHNOLOGY_OPTIONS;
|
||||
return matchSorter([...TECHNOLOGY_OPTIONS], searchValue);
|
||||
}, [searchValue]);
|
||||
|
||||
const toggleOption = useCallback(
|
||||
(option: string) => {
|
||||
if (value.includes(option)) {
|
||||
onChange(value.filter((v) => v !== option));
|
||||
} else {
|
||||
onChange([...value, option]);
|
||||
}
|
||||
},
|
||||
[value, onChange]
|
||||
);
|
||||
|
||||
const removeItem = useCallback(
|
||||
(item: string) => {
|
||||
if (value.includes(item)) {
|
||||
onChange(value.filter((v) => v !== item));
|
||||
} else {
|
||||
onCustomValuesChange(customValues.filter((v) => v !== item));
|
||||
}
|
||||
},
|
||||
[value, onChange, customValues, onCustomValuesChange]
|
||||
);
|
||||
|
||||
const addCustomValue = useCallback(() => {
|
||||
const trimmed = otherInputValue.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const matchedOption = TECHNOLOGY_OPTIONS.find(
|
||||
(opt) => opt.toLowerCase() === trimmed.toLowerCase()
|
||||
);
|
||||
|
||||
if (matchedOption) {
|
||||
if (!value.includes(matchedOption)) {
|
||||
onChange([...value, matchedOption]);
|
||||
}
|
||||
} else if (!customValues.includes(trimmed) && !value.includes(trimmed)) {
|
||||
onCustomValuesChange([...customValues, trimmed]);
|
||||
}
|
||||
|
||||
setOtherInputValue("");
|
||||
}, [otherInputValue, customValues, onCustomValuesChange, value, onChange]);
|
||||
|
||||
const handleOtherKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addCustomValue();
|
||||
}
|
||||
},
|
||||
[addCustomValue]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{allSelected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{allSelected.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-sm border py-0.5 pl-1.5 pr-1 text-xs font-medium text-text-bright",
|
||||
getPillColor(item)
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(item)}
|
||||
aria-label={`Remove ${item}`}
|
||||
className="ml-0.5 flex items-center transition hover:text-text-bright/70"
|
||||
>
|
||||
<XMarkIcon className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Ariakit.ComboboxProvider
|
||||
resetValueOnHide
|
||||
setValue={(val) => {
|
||||
setSearchValue(val);
|
||||
}}
|
||||
>
|
||||
<Ariakit.SelectProvider
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
value={value}
|
||||
setValue={(v) => {
|
||||
if (Array.isArray(v)) {
|
||||
onChange(v);
|
||||
}
|
||||
}}
|
||||
virtualFocus
|
||||
>
|
||||
<Ariakit.Select className="group flex h-8 w-full items-center rounded bg-background-hover pl-2 pr-2.5 text-sm text-text-dimmed ring-border-bright transition focus-custom hover:bg-secondary hover:ring-1">
|
||||
<div className="flex grow items-center">
|
||||
<CubeIcon className="mr-1.5 size-4 flex-none text-text-dimmed" />
|
||||
<span>Select your technologies…</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 flex-none text-text-dimmed transition group-hover:text-text-bright" />
|
||||
</Ariakit.Select>
|
||||
|
||||
<Ariakit.SelectPopover
|
||||
gutter={5}
|
||||
unmountOnHide
|
||||
className={cn(
|
||||
"z-50 flex flex-col overflow-clip rounded border border-grid-bright bg-background-bright shadow-md outline-hidden animate-in fade-in-40",
|
||||
"min-w-[max(180px,var(--popover-anchor-width))]",
|
||||
"max-w-[min(480px,var(--popover-available-width))]",
|
||||
"max-h-[min(400px,var(--popover-available-height))]"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-9 w-full flex-none items-center gap-2 border-b border-grid-dimmed bg-transparent px-3 text-xs text-text-dimmed outline-hidden">
|
||||
<MagnifyingGlassIcon className="size-3.5 flex-none text-text-dimmed" />
|
||||
<Ariakit.Combobox
|
||||
autoSelect
|
||||
placeholder="Search technologies…"
|
||||
className="flex-1 bg-transparent text-xs text-text-dimmed outline-hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Ariakit.ComboboxList className="overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control focus-custom">
|
||||
{filteredOptions.map((option) => (
|
||||
<Ariakit.ComboboxItem
|
||||
key={option}
|
||||
className="group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleOption(option);
|
||||
}}
|
||||
>
|
||||
<div className="flex h-8 w-full items-center gap-2 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary">
|
||||
<CheckboxIndicator checked={value.includes(option)} />
|
||||
<span className="grow truncate text-text-bright">{option}</span>
|
||||
</div>
|
||||
</Ariakit.ComboboxItem>
|
||||
))}
|
||||
|
||||
{filteredOptions.length === 0 && searchValue && (
|
||||
<div className="px-3 py-2 text-xs text-text-dimmed">
|
||||
No matches for “{searchValue}”
|
||||
</div>
|
||||
)}
|
||||
</Ariakit.ComboboxList>
|
||||
|
||||
<div className="sticky bottom-0 border-t border-grid-bright bg-background-bright px-1 py-1">
|
||||
{showOtherInput ? (
|
||||
<div className="flex h-8 w-full items-center rounded-sm bg-tertiary pl-0 pr-2 ring-1 ring-border-bright">
|
||||
<input
|
||||
ref={otherInputRef}
|
||||
type="text"
|
||||
value={otherInputValue}
|
||||
onChange={(e) => setOtherInputValue(e.target.value)}
|
||||
onKeyDown={handleOtherKeyDown}
|
||||
placeholder="Type and press Enter to add"
|
||||
className="flex-1 border-none bg-transparent pl-2 text-2sm text-text-bright shadow-none outline-hidden ring-0 placeholder:text-text-dimmed focus:border-none focus:outline-hidden focus:ring-0"
|
||||
autoFocus
|
||||
/>
|
||||
<ShortcutKey
|
||||
shortcut={{ key: "Enter" }}
|
||||
variant="small"
|
||||
className={cn(
|
||||
"mr-1.5 transition-opacity duration-150",
|
||||
otherInputValue.length > 0 ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOtherInputValue("");
|
||||
setShowOtherInput(false);
|
||||
}}
|
||||
className="flex items-center text-text-dimmed hover:text-text-bright"
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-sm px-2 text-2sm text-text-dimmed hover:bg-tertiary"
|
||||
onClick={() => {
|
||||
setShowOtherInput(true);
|
||||
setTimeout(() => otherInputRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-4 flex-none" />
|
||||
<span>Other (not listed)</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Ariakit.SelectPopover>
|
||||
</Ariakit.SelectProvider>
|
||||
</Ariakit.ComboboxProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user