Skip to content

Commit 34fe945

Browse files
committed
Options for the the what technology do you use question
1 parent 0dc7a38 commit 34fe945

File tree

1 file changed

+322
-0
lines changed

1 file changed

+322
-0
lines changed
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
import * as Ariakit from "@ariakit/react";
2+
import { XMarkIcon, PlusIcon, MagnifyingGlassIcon } from "@heroicons/react/20/solid";
3+
import { useCallback, useMemo, useRef, useState } from "react";
4+
import { cn } from "~/utils/cn";
5+
import { matchSorter } from "match-sorter";
6+
7+
const pillColors = [
8+
"bg-green-800/40 border-green-600/50",
9+
"bg-teal-800/40 border-teal-600/50",
10+
"bg-blue-800/40 border-blue-600/50",
11+
"bg-indigo-800/40 border-indigo-600/50",
12+
"bg-violet-800/40 border-violet-600/50",
13+
"bg-purple-800/40 border-purple-600/50",
14+
"bg-fuchsia-800/40 border-fuchsia-600/50",
15+
"bg-pink-800/40 border-pink-600/50",
16+
"bg-rose-800/40 border-rose-600/50",
17+
"bg-orange-800/40 border-orange-600/50",
18+
"bg-amber-800/40 border-amber-600/50",
19+
"bg-yellow-800/40 border-yellow-600/50",
20+
"bg-lime-800/40 border-lime-600/50",
21+
"bg-emerald-800/40 border-emerald-600/50",
22+
"bg-cyan-800/40 border-cyan-600/50",
23+
"bg-sky-800/40 border-sky-600/50",
24+
];
25+
26+
function getPillColor(value: string): string {
27+
let hash = 0;
28+
for (let i = 0; i < value.length; i++) {
29+
hash = (hash << 5) - hash + value.charCodeAt(i);
30+
hash |= 0;
31+
}
32+
return pillColors[Math.abs(hash) % pillColors.length];
33+
}
34+
35+
export const TECHNOLOGY_OPTIONS = [
36+
"Angular",
37+
"Anthropic",
38+
"Astro",
39+
"AWS",
40+
"Azure",
41+
"BullMQ",
42+
"Bun",
43+
"Celery",
44+
"Clerk",
45+
"Cloudflare",
46+
"Cohere",
47+
"Convex",
48+
"Deno",
49+
"Docker",
50+
"Drizzle",
51+
"DynamoDB",
52+
"Elevenlabs",
53+
"Express",
54+
"Fastify",
55+
"Firebase",
56+
"Fly.io",
57+
"GCP",
58+
"GraphQL",
59+
"Hono",
60+
"Hugging Face",
61+
"Inngest",
62+
"Kafka",
63+
"Kubernetes",
64+
"Laravel",
65+
"LangChain",
66+
"Mistral",
67+
"MongoDB",
68+
"MySQL",
69+
"Neon",
70+
"Nest.js",
71+
"Next.js",
72+
"Node.js",
73+
"Nuxt",
74+
"OpenAI",
75+
"PlanetScale",
76+
"PostgreSQL",
77+
"Prisma",
78+
"RabbitMQ",
79+
"Railway",
80+
"React",
81+
"Redis",
82+
"Remix",
83+
"Render",
84+
"Replicate",
85+
"Resend",
86+
"SQLite",
87+
"Stripe",
88+
"Supabase",
89+
"SvelteKit",
90+
"Temporal",
91+
"tRPC",
92+
"Turso",
93+
"Upstash",
94+
"Vercel",
95+
"Vue",
96+
] as const;
97+
98+
type TechnologyPickerProps = {
99+
value: string[];
100+
onChange: (value: string[]) => void;
101+
customValues: string[];
102+
onCustomValuesChange: (values: string[]) => void;
103+
};
104+
105+
export function TechnologyPicker({
106+
value,
107+
onChange,
108+
customValues,
109+
onCustomValuesChange,
110+
}: TechnologyPickerProps) {
111+
const [open, setOpen] = useState(false);
112+
const [searchValue, setSearchValue] = useState("");
113+
const [otherInputValue, setOtherInputValue] = useState("");
114+
const [showOtherInput, setShowOtherInput] = useState(false);
115+
const otherInputRef = useRef<HTMLInputElement>(null);
116+
117+
const allSelected = useMemo(() => [...value, ...customValues], [value, customValues]);
118+
119+
const filteredOptions = useMemo(() => {
120+
if (!searchValue) return TECHNOLOGY_OPTIONS;
121+
return matchSorter([...TECHNOLOGY_OPTIONS], searchValue);
122+
}, [searchValue]);
123+
124+
const toggleOption = useCallback(
125+
(option: string) => {
126+
if (value.includes(option)) {
127+
onChange(value.filter((v) => v !== option));
128+
} else {
129+
onChange([...value, option]);
130+
}
131+
},
132+
[value, onChange]
133+
);
134+
135+
const removeItem = useCallback(
136+
(item: string) => {
137+
if (value.includes(item)) {
138+
onChange(value.filter((v) => v !== item));
139+
} else {
140+
onCustomValuesChange(customValues.filter((v) => v !== item));
141+
}
142+
},
143+
[value, onChange, customValues, onCustomValuesChange]
144+
);
145+
146+
const addCustomValue = useCallback(() => {
147+
const trimmed = otherInputValue.trim();
148+
if (trimmed && !customValues.includes(trimmed) && !value.includes(trimmed)) {
149+
onCustomValuesChange([...customValues, trimmed]);
150+
setOtherInputValue("");
151+
}
152+
}, [otherInputValue, customValues, onCustomValuesChange, value]);
153+
154+
const handleOtherKeyDown = useCallback(
155+
(e: React.KeyboardEvent) => {
156+
if (e.key === "Enter") {
157+
e.preventDefault();
158+
e.stopPropagation();
159+
addCustomValue();
160+
}
161+
},
162+
[addCustomValue]
163+
);
164+
165+
return (
166+
<div className="flex flex-col gap-2">
167+
{allSelected.length > 0 && (
168+
<div className="flex flex-wrap gap-1.5">
169+
{allSelected.map((item) => (
170+
<span
171+
key={item}
172+
className={cn(
173+
"flex items-center gap-1 rounded-sm border py-0.5 pl-1.5 pr-1 text-xs font-medium text-white",
174+
getPillColor(item)
175+
)}
176+
>
177+
{item}
178+
<button
179+
type="button"
180+
onClick={() => removeItem(item)}
181+
className="ml-0.5 flex items-center hover:text-white/70"
182+
>
183+
<XMarkIcon className="size-3.5" />
184+
</button>
185+
</span>
186+
))}
187+
</div>
188+
)}
189+
190+
<Ariakit.ComboboxProvider
191+
resetValueOnHide
192+
setValue={(val) => {
193+
setSearchValue(val);
194+
}}
195+
>
196+
<Ariakit.SelectProvider
197+
open={open}
198+
setOpen={setOpen}
199+
value={value}
200+
setValue={(v) => {
201+
if (Array.isArray(v)) {
202+
onChange(v);
203+
}
204+
}}
205+
virtualFocus
206+
>
207+
<Ariakit.Select
208+
className="flex h-9 w-full items-center rounded border border-charcoal-650 bg-charcoal-750 px-3 text-sm text-text-dimmed focus-custom hover:border-charcoal-600"
209+
onClick={() => setOpen(true)}
210+
>
211+
<MagnifyingGlassIcon className="mr-2 size-4 flex-none text-text-dimmed" />
212+
<span>Select your technologies…</span>
213+
</Ariakit.Select>
214+
215+
<Ariakit.SelectPopover
216+
gutter={5}
217+
unmountOnHide
218+
className={cn(
219+
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
220+
"min-w-[max(180px,var(--popover-anchor-width))]",
221+
"max-w-[min(480px,var(--popover-available-width))]",
222+
"max-h-[min(400px,var(--popover-available-height))]"
223+
)}
224+
>
225+
<div className="flex h-9 w-full flex-none items-center border-b border-grid-dimmed bg-transparent px-3 text-xs text-text-dimmed outline-none">
226+
<Ariakit.Combobox
227+
autoSelect
228+
placeholder="Search technologies…"
229+
className="flex-1 bg-transparent text-xs text-text-dimmed outline-none"
230+
/>
231+
</div>
232+
233+
<Ariakit.ComboboxList className="overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 focus-custom">
234+
{filteredOptions.map((option) => (
235+
<Ariakit.ComboboxItem
236+
key={option}
237+
className="group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1"
238+
onClick={(e) => {
239+
e.preventDefault();
240+
toggleOption(option);
241+
}}
242+
>
243+
<div className="flex h-8 w-full items-center gap-2 rounded-sm px-2 hover:bg-tertiary group-data-[active-item=true]:bg-tertiary">
244+
<div
245+
className={cn(
246+
"flex size-4 flex-none items-center justify-center rounded border",
247+
value.includes(option)
248+
? "border-indigo-500 bg-indigo-600"
249+
: "border-charcoal-600 bg-charcoal-700"
250+
)}
251+
>
252+
{value.includes(option) && (
253+
<svg className="size-3 text-white" viewBox="0 0 12 12" fill="none">
254+
<path
255+
d="M2.5 6L5 8.5L9.5 3.5"
256+
stroke="currentColor"
257+
strokeWidth="1.5"
258+
strokeLinecap="round"
259+
strokeLinejoin="round"
260+
/>
261+
</svg>
262+
)}
263+
</div>
264+
<span className="grow truncate text-text-bright">{option}</span>
265+
</div>
266+
</Ariakit.ComboboxItem>
267+
))}
268+
269+
{filteredOptions.length === 0 && !searchValue && (
270+
<div className="px-3 py-2 text-xs text-text-dimmed">No options</div>
271+
)}
272+
273+
{filteredOptions.length === 0 && searchValue && (
274+
<div className="px-3 py-2 text-xs text-text-dimmed">
275+
No matches for &ldquo;{searchValue}&rdquo;
276+
</div>
277+
)}
278+
</Ariakit.ComboboxList>
279+
280+
<div className="sticky bottom-0 border-t border-charcoal-700 bg-background-bright">
281+
{showOtherInput ? (
282+
<div className="flex items-center px-3 py-2">
283+
<input
284+
ref={otherInputRef}
285+
type="text"
286+
value={otherInputValue}
287+
onChange={(e) => setOtherInputValue(e.target.value)}
288+
onKeyDown={handleOtherKeyDown}
289+
placeholder="Type and press Enter to add"
290+
className="flex-1 bg-transparent text-xs text-text-bright outline-none placeholder:text-text-dimmed"
291+
autoFocus
292+
/>
293+
<button
294+
type="button"
295+
onClick={() => {
296+
addCustomValue();
297+
}}
298+
className="ml-2 text-xs text-indigo-400 hover:text-indigo-300"
299+
>
300+
Add
301+
</button>
302+
</div>
303+
) : (
304+
<button
305+
type="button"
306+
className="group flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-2sm text-text-dimmed hover:bg-tertiary"
307+
onClick={() => {
308+
setShowOtherInput(true);
309+
setTimeout(() => otherInputRef.current?.focus(), 0);
310+
}}
311+
>
312+
<PlusIcon className="size-4 flex-none" />
313+
<span>Other (not listed)</span>
314+
</button>
315+
)}
316+
</div>
317+
</Ariakit.SelectPopover>
318+
</Ariakit.SelectProvider>
319+
</Ariakit.ComboboxProvider>
320+
</div>
321+
);
322+
}

0 commit comments

Comments
 (0)