Skip to content

Commit 84b6480

Browse files
committed
feat: replace native select elements with custom DropdownSelect component using Headless UI in CreateCardModal
1 parent e77407a commit 84b6480

2 files changed

Lines changed: 119 additions & 29 deletions

File tree

dev.db

0 Bytes
Binary file not shown.

src/components/cards/CreateCardModal.tsx

Lines changed: 119 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
'use client';
77

88
import { Fragment, useCallback, useEffect, useRef, useState } from 'react';
9-
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
10-
import { XMarkIcon, PhotoIcon, CheckIcon } from '@heroicons/react/24/outline';
9+
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
10+
import { XMarkIcon, PhotoIcon, CheckIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
11+
import { createPortal } from 'react-dom';
1112
import { cn } from '@/lib/utils';
1213
import { processImage, validateImageFile } from '@/lib/utils/imageUtils';
1314
import { processDocFile, validateDocFile, formatFileSize, getFileIcon } from '@/lib/utils/fileUtils';
@@ -22,6 +23,109 @@ interface CreateCardModalProps {
2223
preselectedCategoryId?: string;
2324
}
2425

26+
interface DropdownSelectProps {
27+
id: string;
28+
value: string;
29+
onChange: (val: string) => void;
30+
options: { id: string; name: string }[];
31+
}
32+
33+
function DropdownSelect({ id, value, onChange, options }: DropdownSelectProps) {
34+
const selectedOption = options.find((o) => o.id === value) || options[0];
35+
36+
return (
37+
<Listbox value={value} onChange={onChange}>
38+
{({ open }) => (
39+
<>
40+
<ListboxButton
41+
data-select-id={id}
42+
className={cn(
43+
"flex w-full items-center justify-between gap-2 px-3 py-2 rounded-xl text-sm font-medium",
44+
"transition-all duration-200 border",
45+
"surface-card surface-card--subtle hover:border-[color-mix(in_srgb,var(--accent-border)_40%,transparent)] bg-[rgba(5,6,11,0.9)] text-(--text-strong)"
46+
)}
47+
>
48+
<span className="truncate">{selectedOption?.name || 'Select option'}</span>
49+
<ChevronDownIcon className="w-4 h-4 opacity-60 shrink-0" />
50+
</ListboxButton>
51+
52+
{globalThis.window !== undefined && open && createPortal(
53+
<Transition
54+
show={open}
55+
as={Fragment}
56+
enter="transition ease-out duration-100"
57+
enterFrom="transform opacity-0 scale-95"
58+
enterTo="transform opacity-100 scale-100"
59+
leave="transition ease-in duration-75"
60+
leaveFrom="transform opacity-100 scale-100"
61+
leaveTo="transform opacity-0 scale-95"
62+
>
63+
<ListboxOptions
64+
static
65+
className="fixed rounded-2xl focus:outline-none z-9999 overflow-hidden shadow-[0_26px_70px_rgba(0,0,0,0.75)]"
66+
style={{
67+
top: `${(document.querySelector(`[data-select-id="${id}"]`) as HTMLElement)?.getBoundingClientRect().bottom + 8 || 0}px`,
68+
left: `${(document.querySelector(`[data-select-id="${id}"]`) as HTMLElement)?.getBoundingClientRect().left || 0}px`,
69+
width: `${(document.querySelector(`[data-select-id="${id}"]`) as HTMLElement)?.getBoundingClientRect().width || 256}px`,
70+
backgroundColor: 'color-mix(in srgb, var(--background-muted) 50%, transparent)',
71+
backdropFilter: 'blur(24px)',
72+
WebkitBackdropFilter: 'blur(24px)',
73+
border: '1px solid color-mix(in srgb, var(--glass-border) 10%, transparent)',
74+
maxHeight: '250px',
75+
overflowY: 'auto'
76+
}}
77+
>
78+
<div className="p-2 relative flex flex-col gap-0.5" style={{ color: '#f8fafc' }}>
79+
{options.map((option) => (
80+
<ListboxOption key={option.id} value={option.id}>
81+
{({ focus, selected }) => (
82+
<div
83+
className={cn(
84+
"group relative w-full flex items-center gap-3 px-3 py-2.5 rounded-[20px] text-left transition-all duration-300 ease-out overflow-hidden cursor-pointer",
85+
"bg-[color-mix(in_srgb,var(--background)_90%,transparent)]",
86+
selected && "border border-transparent",
87+
!selected && focus && "scale-[1.02]"
88+
)}
89+
>
90+
{(selected || focus) && (
91+
<>
92+
<div className="absolute inset-0 opacity-70 transition-opacity duration-500 backdrop-blur-md">
93+
<div
94+
className="absolute inset-0 mix-blend-screen transition-opacity duration-300"
95+
style={{
96+
backgroundImage: `repeating-linear-gradient(125deg, transparent 0%, transparent 15%, color-mix(in srgb, var(--accent-primary) 25%, transparent) 25%, transparent 35%, transparent 50%)`,
97+
backgroundSize: '200%',
98+
}}
99+
/>
100+
</div>
101+
<div className="absolute inset-[1.5px] rounded-[18.5px] bg-[color-mix(in_srgb,var(--background-muted)_95%,transparent)] transition-colors duration-300 group-hover:bg-[color-mix(in_srgb,var(--background-muted)_80%,transparent)] pointer-events-none" />
102+
</>
103+
)}
104+
<div className="relative flex-1 min-w-0">
105+
<p
106+
className="text-sm font-medium transition-colors duration-300"
107+
style={{
108+
color: selected ? 'var(--accent-primary)' : '#f8fafc',
109+
}}
110+
>
111+
{option.name}
112+
</p>
113+
</div>
114+
</div>
115+
)}
116+
</ListboxOption>
117+
))}
118+
</div>
119+
</ListboxOptions>
120+
</Transition>,
121+
document.body
122+
)}
123+
</>
124+
)}
125+
</Listbox>
126+
);
127+
}
128+
25129
export default function CreateCardModal({
26130
isOpen,
27131
onClose,
@@ -337,38 +441,28 @@ export default function CreateCardModal({
337441
<div className="grid grid-cols-2 gap-4">
338442
<div>
339443
<label htmlFor="card-type" className="block text-xs font-medium mb-1">Type</label>
340-
<select
444+
<DropdownSelect
341445
id="card-type"
342446
value={type}
343-
onChange={(e) => {
344-
const next = e.target.value as typeof type;
447+
onChange={(val) => {
448+
const next = val as typeof type;
345449
setType(next);
346450
setContent('');
347451
resetFileState();
348452
}}
349-
className="w-full rounded-lg border border-[color-mix(in_srgb,var(--card-border)_80%,transparent)] px-3 py-2 bg-[color-mix(in_srgb,var(--surface-card)_90%,transparent)] text-(--text-strong) focus:outline-none focus:ring-2 focus:ring-[color-mix(in_srgb,var(--accent-primary)_50%,transparent)] [&>option]:bg-(--surface-card) [&>option]:text-(--text-strong)"
350-
>
351-
{['bookmark', 'snippet', 'command', 'doc', 'image', 'note'].map((t) => (
352-
<option key={t} value={t}>
353-
{t}
354-
</option>
355-
))}
356-
</select>
453+
options={['bookmark', 'snippet', 'command', 'doc', 'image', 'note'].map(t => ({ id: t, name: t.charAt(0).toUpperCase() + t.slice(1) }))}
454+
/>
357455
</div>
358456
<div>
359457
<div className="flex items-center justify-between mb-1">
360458
<label htmlFor="card-category" className="text-xs font-medium">Category</label>
361459
</div>
362-
<select
460+
<DropdownSelect
363461
id="card-category"
364462
value={categoryId}
365-
onChange={(e) => setCategoryId(e.target.value)}
366-
className="w-full rounded-lg border border-[color-mix(in_srgb,var(--card-border)_80%,transparent)] px-3 py-2 bg-[color-mix(in_srgb,var(--surface-card)_90%,transparent)] text-(--text-strong) focus:outline-none focus:ring-2 focus:ring-[color-mix(in_srgb,var(--accent-primary)_50%,transparent)] [&>option]:bg-(--surface-card) [&>option]:text-(--text-strong)"
367-
>
368-
{categories.map((c) => (
369-
<option key={c.id} value={c.id}>{c.name}</option>
370-
))}
371-
</select>
463+
onChange={setCategoryId}
464+
options={categories}
465+
/>
372466
</div>
373467
</div>
374468

@@ -397,16 +491,12 @@ export default function CreateCardModal({
397491
{(type === 'snippet' || type === 'command') && (
398492
<div>
399493
<label htmlFor="card-language" className="block text-xs font-medium mb-1">Language</label>
400-
<select
494+
<DropdownSelect
401495
id="card-language"
402496
value={language}
403-
onChange={(e) => setLanguage(e.target.value)}
404-
className="w-full rounded-lg border border-[color-mix(in_srgb,var(--card-border)_80%,transparent)] px-3 py-2 bg-[color-mix(in_srgb,var(--surface-card)_90%,transparent)] text-(--text-strong) focus:outline-none focus:ring-2 focus:ring-[color-mix(in_srgb,var(--accent-primary)_50%,transparent)] [&>option]:bg-(--surface-card) [&>option]:text-(--text-strong)"
405-
>
406-
{LANGUAGE_OPTIONS.map((lang) => (
407-
<option key={lang.value} value={lang.value}>{lang.label}</option>
408-
))}
409-
</select>
497+
onChange={setLanguage}
498+
options={LANGUAGE_OPTIONS.map(lang => ({ id: lang.value, name: lang.label }))}
499+
/>
410500
</div>
411501
)}
412502

0 commit comments

Comments
 (0)