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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
|
<script lang="ts">
import { ChevronDown, Check } from 'lucide-svelte';
interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface Props {
options: Option[];
value: string;
placeholder?: string;
disabled?: boolean;
class?: string;
allowCustom?: boolean; // New prop to allow custom input
onchange?: (value: string) => void;
}
let {
options,
value = $bindable(),
placeholder = "Select...",
disabled = false,
class: className = "",
allowCustom = false,
onchange
}: Props = $props();
let isOpen = $state(false);
let containerRef: HTMLDivElement;
let customInput = $state(""); // State for custom input
let selectedOption = $derived(options.find(o => o.value === value));
// Display label: if option exists use its label, otherwise if custom is allowed use raw value, else placeholder
let displayLabel = $derived(selectedOption ? selectedOption.label : (allowCustom && value ? value : placeholder));
function toggle() {
if (!disabled) {
isOpen = !isOpen;
// When opening, if current value is custom (not in options), pre-fill input
if (isOpen && allowCustom && !selectedOption) {
customInput = value;
}
}
}
function select(option: Option) {
if (option.disabled) return;
value = option.value;
isOpen = false;
onchange?.(option.value);
}
function handleCustomSubmit() {
if (!customInput.trim()) return;
value = customInput.trim();
isOpen = false;
onchange?.(value);
}
function handleKeydown(e: KeyboardEvent) {
if (disabled) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
} else if (e.key === 'Escape') {
isOpen = false;
} else if (e.key === 'ArrowDown' && isOpen) {
e.preventDefault();
const currentIndex = options.findIndex(o => o.value === value);
const nextIndex = Math.min(currentIndex + 1, options.length - 1);
if (!options[nextIndex].disabled) {
value = options[nextIndex].value;
}
} else if (e.key === 'ArrowUp' && isOpen) {
e.preventDefault();
const currentIndex = options.findIndex(o => o.value === value);
const prevIndex = Math.max(currentIndex - 1, 0);
if (!options[prevIndex].disabled) {
value = options[prevIndex].value;
}
}
}
function handleClickOutside(e: MouseEvent) {
if (containerRef && !containerRef.contains(e.target as Node)) {
isOpen = false;
}
}
$effect(() => {
if (isOpen) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
</script>
<div
bind:this={containerRef}
class="relative {className}"
>
<!-- Trigger Button -->
<button
type="button"
onclick={toggle}
onkeydown={handleKeydown}
{disabled}
class="w-full flex items-center justify-between gap-2 px-3 py-2 pr-8 text-left
bg-zinc-900 border border-zinc-700 rounded-md text-sm text-zinc-200
hover:border-zinc-600 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/30
transition-colors cursor-pointer outline-none
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-700"
>
<span class="truncate {(!selectedOption && !value) ? 'text-zinc-500' : ''}">
{displayLabel}
</span>
<ChevronDown
size={14}
class="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 transition-transform duration-200 {isOpen ? 'rotate-180' : ''}"
/>
</button>
<!-- Dropdown Menu -->
{#if isOpen}
<div
class="absolute z-50 w-full mt-1 py-1 bg-zinc-900 border border-zinc-700 rounded-md shadow-xl
max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 flex flex-col"
>
{#if allowCustom}
<div class="px-2 py-2 border-b border-zinc-700/50 mb-1">
<div class="flex gap-2">
<input
type="text"
bind:value={customInput}
placeholder="Custom value..."
class="flex-1 bg-black/30 border border-zinc-700 rounded px-2 py-1 text-xs text-white focus:border-indigo-500 outline-none"
onkeydown={(e) => e.key === 'Enter' && handleCustomSubmit()}
onclick={(e) => e.stopPropagation()}
/>
<button
onclick={(e) => { e.stopPropagation(); handleCustomSubmit(); }}
class="px-2 py-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded text-xs transition-colors"
>
Set
</button>
</div>
</div>
{/if}
{#each options as option}
<button
type="button"
onclick={() => select(option)}
disabled={option.disabled}
class="w-full flex items-center justify-between px-3 py-2 text-sm text-left
transition-colors outline-none
{option.value === value
? 'bg-indigo-600 text-white'
: 'text-zinc-300 hover:bg-zinc-800'}
{option.disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}"
>
<span class="truncate">{option.label}</span>
{#if option.value === value}
<Check size={14} class="shrink-0 ml-2" />
{/if}
</button>
{/each}
</div>
{/if}
</div>
|