forked from PinSpace-Org/GistPin
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathSelect.tsx
More file actions
142 lines (128 loc) · 4.93 KB
/
Copy pathSelect.tsx
File metadata and controls
142 lines (128 loc) · 4.93 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
'use client';
import { useState, useRef, useEffect } from 'react';
export interface SelectOption {
value: string;
label: string;
group?: string;
disabled?: boolean;
}
interface SelectProps {
options: SelectOption[];
value?: string | string[];
onChange?: (value: string | string[]) => void;
placeholder?: string;
multiple?: boolean;
searchable?: boolean;
disabled?: boolean;
label?: string;
}
export default function Select({
options,
value,
onChange,
placeholder = 'Select…',
multiple = false,
searchable = false,
disabled = false,
label,
}: SelectProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const ref = useRef<HTMLDivElement>(null);
const selected = multiple
? (Array.isArray(value) ? value : [])
: (typeof value === 'string' ? value : '');
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const filtered = options.filter((o) =>
o.label.toLowerCase().includes(search.toLowerCase()),
);
const groups = Array.from(new Set(filtered.map((o) => o.group ?? ''))).filter(Boolean);
const ungrouped = filtered.filter((o) => !o.group);
const isSelected = (val: string) =>
multiple ? (selected as string[]).includes(val) : selected === val;
const toggle = (val: string) => {
if (multiple) {
const arr = selected as string[];
const next = arr.includes(val) ? arr.filter((v) => v !== val) : [...arr, val];
onChange?.(next);
} else {
onChange?.(val);
setOpen(false);
}
};
const displayLabel = multiple
? (selected as string[]).length
? (selected as string[]).map((v) => options.find((o) => o.value === v)?.label ?? v).join(', ')
: placeholder
: options.find((o) => o.value === selected)?.label ?? placeholder;
const renderOption = (opt: SelectOption) => (
<button
key={opt.value}
type="button"
disabled={opt.disabled}
onClick={() => !opt.disabled && toggle(opt.value)}
className={`w-full text-left px-3 py-1.5 text-sm rounded flex items-center gap-2 transition-colors ${
opt.disabled
? 'opacity-40 cursor-not-allowed'
: isSelected(opt.value)
? 'bg-brand/10 text-brand font-medium'
: 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200'
}`}
>
{multiple && (
<span className={`h-3.5 w-3.5 rounded border ${isSelected(opt.value) ? 'bg-brand border-brand' : 'border-gray-300'}`} />
)}
{opt.label}
</button>
);
return (
<div ref={ref} className="relative flex flex-col gap-1">
{label && <span className="text-sm font-medium text-gray-700 dark:text-gray-300">{label}</span>}
<button
type="button"
disabled={disabled}
onClick={() => setOpen((v) => !v)}
className="flex h-9 w-full items-center justify-between rounded-lg border border-gray-200 bg-white px-3 text-sm text-gray-700 transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="truncate">{displayLabel}</span>
<svg className={`h-4 w-4 shrink-0 text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{open && (
<div className="absolute top-full z-50 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800">
{searchable && (
<div className="p-2 border-b border-gray-100 dark:border-gray-700">
<input
autoFocus
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search…"
className="w-full rounded border border-gray-200 px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-brand dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
)}
<div className="max-h-52 overflow-y-auto p-1">
{ungrouped.map(renderOption)}
{groups.map((group) => (
<div key={group}>
<p className="px-3 pt-2 pb-1 text-xs font-semibold uppercase tracking-wide text-gray-400">{group}</p>
{filtered.filter((o) => o.group === group).map(renderOption)}
</div>
))}
{filtered.length === 0 && (
<p className="px-3 py-2 text-xs text-gray-400">No options found</p>
)}
</div>
</div>
)}
</div>
);
}