@synthaxai/ui 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +9 -0
- package/README.md +40 -220
- package/dist/data-display/DataTable/DataTable.svelte +14 -8
- package/dist/data-display/DataTable/DataTable.svelte.d.ts +64 -61
- package/dist/data-display/DataTable/DataTable.svelte.d.ts.map +1 -1
- package/dist/forms/DatePicker/DatePicker.svelte +1564 -0
- package/dist/forms/DatePicker/DatePicker.svelte.d.ts +129 -0
- package/dist/forms/DatePicker/DatePicker.svelte.d.ts.map +1 -0
- package/dist/forms/DatePicker/index.d.ts +8 -0
- package/dist/forms/DatePicker/index.d.ts.map +1 -0
- package/dist/forms/DatePicker/index.js +7 -0
- package/dist/forms/MultiSelect/MultiSelect.svelte +1651 -0
- package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts +127 -0
- package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts.map +1 -0
- package/dist/forms/MultiSelect/index.d.ts +9 -0
- package/dist/forms/MultiSelect/index.d.ts.map +1 -0
- package/dist/forms/MultiSelect/index.js +7 -0
- package/dist/forms/SearchInput/SearchInput.svelte +1319 -0
- package/dist/forms/SearchInput/SearchInput.svelte.d.ts +148 -0
- package/dist/forms/SearchInput/SearchInput.svelte.d.ts.map +1 -0
- package/dist/forms/SearchInput/index.d.ts +9 -0
- package/dist/forms/SearchInput/index.d.ts.map +1 -0
- package/dist/forms/SearchInput/index.js +7 -0
- package/dist/forms/TextInput/TextInput.svelte +763 -122
- package/dist/forms/TextInput/TextInput.svelte.d.ts +98 -13
- package/dist/forms/TextInput/TextInput.svelte.d.ts.map +1 -1
- package/dist/forms/TextInput/index.d.ts +7 -1
- package/dist/forms/TextInput/index.d.ts.map +1 -1
- package/dist/forms/TextInput/index.js +7 -1
- package/dist/forms/index.d.ts +3 -0
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/index.js +3 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/package.json +3 -3
|
@@ -0,0 +1,1319 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component SearchInput
|
|
3
|
+
|
|
4
|
+
A fully accessible autocomplete/combobox component following WAI-ARIA 1.2 pattern.
|
|
5
|
+
Supports async search, keyboard navigation, and custom option rendering.
|
|
6
|
+
|
|
7
|
+
Designed for healthcare applications requiring patient, provider, or payer search.
|
|
8
|
+
|
|
9
|
+
@example Basic usage
|
|
10
|
+
<SearchInput
|
|
11
|
+
id="patient-search"
|
|
12
|
+
label="Search Patient"
|
|
13
|
+
value={searchQuery}
|
|
14
|
+
options={patients}
|
|
15
|
+
onselect={(option) => handlePatientSelect(option)}
|
|
16
|
+
onsearch={(query) => searchPatients(query)}
|
|
17
|
+
/>
|
|
18
|
+
|
|
19
|
+
@example With custom rendering
|
|
20
|
+
<SearchInput
|
|
21
|
+
id="payer-search"
|
|
22
|
+
label="Insurance Payer"
|
|
23
|
+
value={payerQuery}
|
|
24
|
+
options={payers}
|
|
25
|
+
onselect={handlePayerSelect}
|
|
26
|
+
>
|
|
27
|
+
{#snippet optionContent(option)}
|
|
28
|
+
<div class="flex items-center gap-2">
|
|
29
|
+
<span class="font-medium">{option.label}</span>
|
|
30
|
+
<span class="text-sm text-muted">{option.metadata?.type}</span>
|
|
31
|
+
</div>
|
|
32
|
+
{/snippet}
|
|
33
|
+
</SearchInput>
|
|
34
|
+
-->
|
|
35
|
+
<script lang="ts">
|
|
36
|
+
import type { Snippet } from 'svelte';
|
|
37
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
38
|
+
import { Search, X, Loader2, AlertCircle, Check, ChevronDown, Info } from 'lucide-svelte';
|
|
39
|
+
import { cn } from '../../utils/cn.js';
|
|
40
|
+
import { generateId, Keys } from '../../utils/keyboard.js';
|
|
41
|
+
import { announce, prefersReducedMotion } from '../../utils/accessibility.js';
|
|
42
|
+
import type { InputMode, AutoComplete } from '../../types/index.js';
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Types
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
type InputSize = 'sm' | 'md' | 'lg';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Search option item with value, label, and optional metadata.
|
|
52
|
+
*/
|
|
53
|
+
export interface SearchOption {
|
|
54
|
+
/** Unique value for the option */
|
|
55
|
+
value: string;
|
|
56
|
+
/** Display label for the option */
|
|
57
|
+
label: string;
|
|
58
|
+
/** Whether the option is disabled */
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
/** Optional description shown below the label */
|
|
61
|
+
description?: string;
|
|
62
|
+
/** Optional icon or visual indicator */
|
|
63
|
+
icon?: typeof Search;
|
|
64
|
+
/** Optional metadata for custom rendering */
|
|
65
|
+
metadata?: Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Grouped search options for categorized results.
|
|
70
|
+
*/
|
|
71
|
+
export interface SearchOptionGroup {
|
|
72
|
+
/** Group label */
|
|
73
|
+
label: string;
|
|
74
|
+
/** Options in this group */
|
|
75
|
+
options: SearchOption[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type SearchOptions = SearchOption[] | SearchOptionGroup[];
|
|
79
|
+
|
|
80
|
+
interface Props extends Omit<HTMLInputAttributes, 'class' | 'size' | 'value' | 'onselect'> {
|
|
81
|
+
/** Unique identifier for the input */
|
|
82
|
+
id?: string;
|
|
83
|
+
/** Form field name */
|
|
84
|
+
name?: string;
|
|
85
|
+
/** Label text for the input */
|
|
86
|
+
label: string;
|
|
87
|
+
/** Current input value (the search query) */
|
|
88
|
+
value?: string;
|
|
89
|
+
/** Currently selected option */
|
|
90
|
+
selected?: SearchOption | null;
|
|
91
|
+
/** Placeholder text */
|
|
92
|
+
placeholder?: string;
|
|
93
|
+
/** Options to display (flat or grouped) */
|
|
94
|
+
options?: SearchOptions;
|
|
95
|
+
/** Whether the field is required */
|
|
96
|
+
required?: boolean;
|
|
97
|
+
/** Whether the field is disabled */
|
|
98
|
+
disabled?: boolean;
|
|
99
|
+
/** Whether the field is read-only */
|
|
100
|
+
readonly?: boolean;
|
|
101
|
+
/** Error message to display */
|
|
102
|
+
error?: string;
|
|
103
|
+
/** Hint text to display below the input */
|
|
104
|
+
hint?: string;
|
|
105
|
+
/** Success message when valid */
|
|
106
|
+
successMessage?: string;
|
|
107
|
+
/** Size of the input */
|
|
108
|
+
size?: InputSize;
|
|
109
|
+
/** Whether to hide the label visually (still accessible) */
|
|
110
|
+
hideLabel?: boolean;
|
|
111
|
+
/** Additional CSS classes for the wrapper */
|
|
112
|
+
class?: string;
|
|
113
|
+
/** Show loading spinner during async search */
|
|
114
|
+
loading?: boolean;
|
|
115
|
+
/** Message to show when no results found */
|
|
116
|
+
emptyMessage?: string;
|
|
117
|
+
/** Minimum characters before triggering search */
|
|
118
|
+
minSearchLength?: number;
|
|
119
|
+
/** Debounce delay for search in milliseconds */
|
|
120
|
+
debounceMs?: number;
|
|
121
|
+
/** Whether to show clear button when has value */
|
|
122
|
+
clearable?: boolean;
|
|
123
|
+
/** Whether to open dropdown on focus (even if empty) */
|
|
124
|
+
openOnFocus?: boolean;
|
|
125
|
+
/** Whether to auto-select first option */
|
|
126
|
+
autoSelect?: boolean;
|
|
127
|
+
/** Whether to allow free text (value not from options) */
|
|
128
|
+
allowFreeText?: boolean;
|
|
129
|
+
/** Icon to display on the left */
|
|
130
|
+
iconLeft?: Snippet;
|
|
131
|
+
/** Custom content for option rendering */
|
|
132
|
+
optionContent?: Snippet<[SearchOption]>;
|
|
133
|
+
/** Custom empty state content */
|
|
134
|
+
emptyContent?: Snippet;
|
|
135
|
+
/** Custom loading content */
|
|
136
|
+
loadingContent?: Snippet;
|
|
137
|
+
/** Input mode for mobile keyboards */
|
|
138
|
+
inputMode?: InputMode;
|
|
139
|
+
/** Autocomplete value */
|
|
140
|
+
autocomplete?: AutoComplete;
|
|
141
|
+
/** Called when search query changes (debounced) */
|
|
142
|
+
onsearch?: (query: string) => void;
|
|
143
|
+
/** Called when an option is selected */
|
|
144
|
+
onselect?: (option: SearchOption) => void;
|
|
145
|
+
/** Called when selection is cleared */
|
|
146
|
+
onclear?: () => void;
|
|
147
|
+
/** Called when input value changes */
|
|
148
|
+
oninput?: (event: Event) => void;
|
|
149
|
+
/** Called when input is focused */
|
|
150
|
+
onfocus?: (event: FocusEvent) => void;
|
|
151
|
+
/** Called when input loses focus */
|
|
152
|
+
onblur?: (event: FocusEvent) => void;
|
|
153
|
+
/** Test ID for e2e testing */
|
|
154
|
+
testId?: string;
|
|
155
|
+
/** Show validation state immediately */
|
|
156
|
+
validateOnMount?: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let {
|
|
160
|
+
id = generateId('search'),
|
|
161
|
+
name,
|
|
162
|
+
label,
|
|
163
|
+
value = $bindable(''),
|
|
164
|
+
selected = $bindable(null),
|
|
165
|
+
placeholder = 'Search...',
|
|
166
|
+
options = [],
|
|
167
|
+
required = false,
|
|
168
|
+
disabled = false,
|
|
169
|
+
readonly = false,
|
|
170
|
+
error = '',
|
|
171
|
+
hint = '',
|
|
172
|
+
successMessage = '',
|
|
173
|
+
size = 'md',
|
|
174
|
+
hideLabel = false,
|
|
175
|
+
class: className = '',
|
|
176
|
+
loading = false,
|
|
177
|
+
emptyMessage = 'No results found',
|
|
178
|
+
minSearchLength = 1,
|
|
179
|
+
debounceMs = 300,
|
|
180
|
+
clearable = true,
|
|
181
|
+
openOnFocus = false,
|
|
182
|
+
autoSelect = false,
|
|
183
|
+
allowFreeText = false,
|
|
184
|
+
iconLeft,
|
|
185
|
+
optionContent,
|
|
186
|
+
emptyContent,
|
|
187
|
+
loadingContent,
|
|
188
|
+
inputMode = 'search',
|
|
189
|
+
autocomplete = 'off',
|
|
190
|
+
onsearch,
|
|
191
|
+
onselect,
|
|
192
|
+
onclear,
|
|
193
|
+
oninput,
|
|
194
|
+
onfocus,
|
|
195
|
+
onblur,
|
|
196
|
+
testId,
|
|
197
|
+
validateOnMount = false,
|
|
198
|
+
...restProps
|
|
199
|
+
}: Props = $props();
|
|
200
|
+
|
|
201
|
+
// ============================================================================
|
|
202
|
+
// State
|
|
203
|
+
// ============================================================================
|
|
204
|
+
|
|
205
|
+
let inputRef: HTMLInputElement | undefined = $state();
|
|
206
|
+
let listboxRef: HTMLDivElement | undefined = $state();
|
|
207
|
+
let isOpen = $state(false);
|
|
208
|
+
let isFocused = $state(false);
|
|
209
|
+
let hasInteracted = $state(false);
|
|
210
|
+
let activeIndex = $state(-1);
|
|
211
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
212
|
+
|
|
213
|
+
// ============================================================================
|
|
214
|
+
// Derived State
|
|
215
|
+
// ============================================================================
|
|
216
|
+
|
|
217
|
+
// Flatten grouped options for navigation
|
|
218
|
+
const flatOptions = $derived.by(() => {
|
|
219
|
+
if (options.length === 0) return [];
|
|
220
|
+
if ('options' in options[0]) {
|
|
221
|
+
return (options as SearchOptionGroup[]).flatMap((g) => g.options);
|
|
222
|
+
}
|
|
223
|
+
return options as SearchOption[];
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Filter to only enabled options for navigation
|
|
227
|
+
const enabledOptions = $derived(flatOptions.filter((o) => !o.disabled));
|
|
228
|
+
|
|
229
|
+
// Check if options are grouped
|
|
230
|
+
const isGrouped = $derived(options.length > 0 && 'options' in options[0]);
|
|
231
|
+
|
|
232
|
+
// Validation display logic
|
|
233
|
+
const shouldValidate = $derived(hasInteracted || validateOnMount);
|
|
234
|
+
const showError = $derived(shouldValidate && !!error);
|
|
235
|
+
const showSuccess = $derived(shouldValidate && !error && !!selected && !!successMessage);
|
|
236
|
+
const isValid = $derived(shouldValidate && !error && !!selected && !!successMessage);
|
|
237
|
+
|
|
238
|
+
// Should show dropdown
|
|
239
|
+
const shouldShowDropdown = $derived(
|
|
240
|
+
isOpen &&
|
|
241
|
+
!disabled &&
|
|
242
|
+
!readonly &&
|
|
243
|
+
(loading || flatOptions.length > 0 || (value.length >= minSearchLength && !loading))
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// Show "no results" state
|
|
247
|
+
const showEmpty = $derived(
|
|
248
|
+
!loading && flatOptions.length === 0 && value.length >= minSearchLength
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// IDs for accessibility
|
|
252
|
+
const listboxId = $derived(`${id}-listbox`);
|
|
253
|
+
const errorId = $derived(`${id}-error`);
|
|
254
|
+
const hintId = $derived(`${id}-hint`);
|
|
255
|
+
const activeDescendantId = $derived(
|
|
256
|
+
activeIndex >= 0 ? `${id}-option-${activeIndex}` : undefined
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// Build aria-describedby
|
|
260
|
+
const ariaDescribedBy = $derived(
|
|
261
|
+
[showError && errorId, hint && hintId].filter(Boolean).join(' ') || undefined
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// ============================================================================
|
|
265
|
+
// Helpers
|
|
266
|
+
// ============================================================================
|
|
267
|
+
|
|
268
|
+
function getOptionIndex(option: SearchOption): number {
|
|
269
|
+
return flatOptions.findIndex((o) => o.value === option.value);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function scrollToOption(index: number) {
|
|
273
|
+
if (!listboxRef || index < 0) return;
|
|
274
|
+
const optionEl = listboxRef.querySelector(`[data-index="${index}"]`);
|
|
275
|
+
if (optionEl && typeof optionEl.scrollIntoView === 'function') {
|
|
276
|
+
optionEl.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ============================================================================
|
|
281
|
+
// Event Handlers
|
|
282
|
+
// ============================================================================
|
|
283
|
+
|
|
284
|
+
function handleInput(e: Event) {
|
|
285
|
+
const target = e.target as HTMLInputElement;
|
|
286
|
+
value = target.value;
|
|
287
|
+
hasInteracted = true;
|
|
288
|
+
|
|
289
|
+
// Clear selection if user modifies text (unless allowFreeText)
|
|
290
|
+
if (selected && !allowFreeText) {
|
|
291
|
+
selected = null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Open dropdown
|
|
295
|
+
if (value.length >= minSearchLength) {
|
|
296
|
+
isOpen = true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Reset active index
|
|
300
|
+
activeIndex = autoSelect && enabledOptions.length > 0 ? 0 : -1;
|
|
301
|
+
|
|
302
|
+
// Debounced search callback
|
|
303
|
+
if (debounceTimer) {
|
|
304
|
+
clearTimeout(debounceTimer);
|
|
305
|
+
}
|
|
306
|
+
debounceTimer = setTimeout(() => {
|
|
307
|
+
if (value.length >= minSearchLength) {
|
|
308
|
+
onsearch?.(value);
|
|
309
|
+
}
|
|
310
|
+
}, debounceMs);
|
|
311
|
+
|
|
312
|
+
oninput?.(e);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function handleFocus(e: FocusEvent) {
|
|
316
|
+
isFocused = true;
|
|
317
|
+
|
|
318
|
+
// Open on focus if configured
|
|
319
|
+
if (openOnFocus && value.length >= minSearchLength) {
|
|
320
|
+
isOpen = true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
onfocus?.(e);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function handleBlur(e: FocusEvent) {
|
|
327
|
+
// Don't close if clicking inside the dropdown
|
|
328
|
+
const relatedTarget = e.relatedTarget as HTMLElement | null;
|
|
329
|
+
if (relatedTarget && listboxRef?.contains(relatedTarget)) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
isFocused = false;
|
|
334
|
+
hasInteracted = true;
|
|
335
|
+
|
|
336
|
+
// Delay closing to allow click events to fire
|
|
337
|
+
setTimeout(() => {
|
|
338
|
+
if (!isFocused) {
|
|
339
|
+
isOpen = false;
|
|
340
|
+
activeIndex = -1;
|
|
341
|
+
}
|
|
342
|
+
}, 150);
|
|
343
|
+
|
|
344
|
+
onblur?.(e);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
348
|
+
if (disabled || readonly) return;
|
|
349
|
+
|
|
350
|
+
switch (e.key) {
|
|
351
|
+
case Keys.ArrowDown:
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
if (!isOpen && value.length >= minSearchLength) {
|
|
354
|
+
isOpen = true;
|
|
355
|
+
activeIndex = 0;
|
|
356
|
+
} else if (isOpen && enabledOptions.length > 0) {
|
|
357
|
+
activeIndex = Math.min(activeIndex + 1, enabledOptions.length - 1);
|
|
358
|
+
// Find actual index in flatOptions
|
|
359
|
+
const targetOption = enabledOptions[activeIndex];
|
|
360
|
+
const actualIndex = getOptionIndex(targetOption);
|
|
361
|
+
scrollToOption(actualIndex);
|
|
362
|
+
}
|
|
363
|
+
break;
|
|
364
|
+
|
|
365
|
+
case Keys.ArrowUp:
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
if (isOpen && enabledOptions.length > 0) {
|
|
368
|
+
activeIndex = Math.max(activeIndex - 1, 0);
|
|
369
|
+
const targetOption = enabledOptions[activeIndex];
|
|
370
|
+
const actualIndex = getOptionIndex(targetOption);
|
|
371
|
+
scrollToOption(actualIndex);
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
|
|
375
|
+
case Keys.Home:
|
|
376
|
+
if (isOpen && enabledOptions.length > 0) {
|
|
377
|
+
e.preventDefault();
|
|
378
|
+
activeIndex = 0;
|
|
379
|
+
scrollToOption(getOptionIndex(enabledOptions[0]));
|
|
380
|
+
}
|
|
381
|
+
break;
|
|
382
|
+
|
|
383
|
+
case Keys.End:
|
|
384
|
+
if (isOpen && enabledOptions.length > 0) {
|
|
385
|
+
e.preventDefault();
|
|
386
|
+
activeIndex = enabledOptions.length - 1;
|
|
387
|
+
scrollToOption(getOptionIndex(enabledOptions[activeIndex]));
|
|
388
|
+
}
|
|
389
|
+
break;
|
|
390
|
+
|
|
391
|
+
case Keys.Enter:
|
|
392
|
+
if (isOpen && activeIndex >= 0 && activeIndex < enabledOptions.length) {
|
|
393
|
+
e.preventDefault();
|
|
394
|
+
selectOption(enabledOptions[activeIndex]);
|
|
395
|
+
}
|
|
396
|
+
break;
|
|
397
|
+
|
|
398
|
+
case Keys.Escape:
|
|
399
|
+
if (isOpen) {
|
|
400
|
+
e.preventDefault();
|
|
401
|
+
isOpen = false;
|
|
402
|
+
activeIndex = -1;
|
|
403
|
+
inputRef?.focus();
|
|
404
|
+
}
|
|
405
|
+
break;
|
|
406
|
+
|
|
407
|
+
case Keys.Tab:
|
|
408
|
+
// Allow tab but close dropdown
|
|
409
|
+
isOpen = false;
|
|
410
|
+
activeIndex = -1;
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function selectOption(option: SearchOption) {
|
|
416
|
+
if (option.disabled) return;
|
|
417
|
+
|
|
418
|
+
selected = option;
|
|
419
|
+
value = option.label;
|
|
420
|
+
isOpen = false;
|
|
421
|
+
activeIndex = -1;
|
|
422
|
+
hasInteracted = true;
|
|
423
|
+
|
|
424
|
+
// Announce selection to screen readers
|
|
425
|
+
announce(`Selected: ${option.label}`, 'polite');
|
|
426
|
+
|
|
427
|
+
onselect?.(option);
|
|
428
|
+
inputRef?.focus();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function handleOptionClick(option: SearchOption, e: MouseEvent) {
|
|
432
|
+
e.preventDefault();
|
|
433
|
+
e.stopPropagation();
|
|
434
|
+
selectOption(option);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function handleOptionMouseEnter(index: number) {
|
|
438
|
+
// Find the index in enabled options
|
|
439
|
+
const option = flatOptions[index];
|
|
440
|
+
if (!option?.disabled) {
|
|
441
|
+
const enabledIndex = enabledOptions.findIndex((o) => o.value === option.value);
|
|
442
|
+
if (enabledIndex >= 0) {
|
|
443
|
+
activeIndex = enabledIndex;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function handleClear(e: MouseEvent) {
|
|
449
|
+
e.preventDefault();
|
|
450
|
+
e.stopPropagation();
|
|
451
|
+
value = '';
|
|
452
|
+
selected = null;
|
|
453
|
+
activeIndex = -1;
|
|
454
|
+
isOpen = false;
|
|
455
|
+
hasInteracted = true;
|
|
456
|
+
announce('Selection cleared', 'polite');
|
|
457
|
+
onclear?.();
|
|
458
|
+
inputRef?.focus();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function handleToggleClick(e: MouseEvent) {
|
|
462
|
+
e.preventDefault();
|
|
463
|
+
e.stopPropagation();
|
|
464
|
+
if (!disabled && !readonly) {
|
|
465
|
+
isOpen = !isOpen;
|
|
466
|
+
if (isOpen) {
|
|
467
|
+
inputRef?.focus();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ============================================================================
|
|
473
|
+
// Cleanup
|
|
474
|
+
// ============================================================================
|
|
475
|
+
|
|
476
|
+
$effect(() => {
|
|
477
|
+
return () => {
|
|
478
|
+
if (debounceTimer) {
|
|
479
|
+
clearTimeout(debounceTimer);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
});
|
|
483
|
+
</script>
|
|
484
|
+
|
|
485
|
+
<div
|
|
486
|
+
class={cn(
|
|
487
|
+
'search-input-wrapper',
|
|
488
|
+
`search-input-${size}`,
|
|
489
|
+
disabled && 'search-input-disabled',
|
|
490
|
+
className
|
|
491
|
+
)}
|
|
492
|
+
>
|
|
493
|
+
<!-- Label -->
|
|
494
|
+
<label for={id} class={cn('search-input-label', hideLabel && 'sr-only')}>
|
|
495
|
+
{label}
|
|
496
|
+
{#if required}
|
|
497
|
+
<span class="search-input-required" aria-hidden="true">*</span>
|
|
498
|
+
{/if}
|
|
499
|
+
{#if hint && !hideLabel}
|
|
500
|
+
<span class="search-input-hint-icon" title={hint}>
|
|
501
|
+
<Info size={14} />
|
|
502
|
+
</span>
|
|
503
|
+
{/if}
|
|
504
|
+
</label>
|
|
505
|
+
|
|
506
|
+
<!-- Combobox container -->
|
|
507
|
+
<div class="search-input-container">
|
|
508
|
+
<!-- Input wrapper -->
|
|
509
|
+
<div class="search-input-field-wrapper">
|
|
510
|
+
{#if iconLeft}
|
|
511
|
+
<span class={cn('search-input-icon-left', isFocused && 'search-input-icon-focused')} aria-hidden="true">
|
|
512
|
+
{@render iconLeft()}
|
|
513
|
+
</span>
|
|
514
|
+
{:else}
|
|
515
|
+
<span class={cn('search-input-icon-left', isFocused && 'search-input-icon-focused')} aria-hidden="true">
|
|
516
|
+
<Search size={size === 'sm' ? 14 : size === 'lg' ? 18 : 16} />
|
|
517
|
+
</span>
|
|
518
|
+
{/if}
|
|
519
|
+
|
|
520
|
+
<input
|
|
521
|
+
bind:this={inputRef}
|
|
522
|
+
{id}
|
|
523
|
+
{name}
|
|
524
|
+
type="text"
|
|
525
|
+
role="combobox"
|
|
526
|
+
{value}
|
|
527
|
+
{placeholder}
|
|
528
|
+
{disabled}
|
|
529
|
+
{readonly}
|
|
530
|
+
{required}
|
|
531
|
+
inputmode={inputMode}
|
|
532
|
+
{autocomplete}
|
|
533
|
+
aria-expanded={isOpen}
|
|
534
|
+
aria-controls={listboxId}
|
|
535
|
+
aria-autocomplete="list"
|
|
536
|
+
aria-haspopup="listbox"
|
|
537
|
+
aria-activedescendant={activeDescendantId}
|
|
538
|
+
aria-invalid={showError}
|
|
539
|
+
aria-describedby={ariaDescribedBy}
|
|
540
|
+
aria-required={required}
|
|
541
|
+
data-testid={testId}
|
|
542
|
+
class={cn(
|
|
543
|
+
'search-input-field',
|
|
544
|
+
'search-input-has-icon-left',
|
|
545
|
+
(clearable || showError || isValid || loading) && 'search-input-has-icon-right',
|
|
546
|
+
showError && 'search-input-error',
|
|
547
|
+
isValid && 'search-input-valid'
|
|
548
|
+
)}
|
|
549
|
+
oninput={handleInput}
|
|
550
|
+
onfocus={handleFocus}
|
|
551
|
+
onblur={handleBlur}
|
|
552
|
+
onkeydown={handleKeyDown}
|
|
553
|
+
{...restProps}
|
|
554
|
+
/>
|
|
555
|
+
|
|
556
|
+
<!-- Right icons -->
|
|
557
|
+
<div class="search-input-icons-right">
|
|
558
|
+
{#if loading}
|
|
559
|
+
<span class="search-input-icon-loading" aria-hidden="true">
|
|
560
|
+
<Loader2 size={size === 'sm' ? 14 : size === 'lg' ? 18 : 16} class="search-input-spinner" />
|
|
561
|
+
</span>
|
|
562
|
+
{:else if showError}
|
|
563
|
+
<span class="search-input-icon-error" aria-hidden="true">
|
|
564
|
+
<AlertCircle size={size === 'sm' ? 14 : size === 'lg' ? 18 : 16} />
|
|
565
|
+
</span>
|
|
566
|
+
{:else if isValid}
|
|
567
|
+
<span class="search-input-icon-success" aria-hidden="true">
|
|
568
|
+
<Check size={size === 'sm' ? 14 : size === 'lg' ? 18 : 16} />
|
|
569
|
+
</span>
|
|
570
|
+
{/if}
|
|
571
|
+
|
|
572
|
+
{#if clearable && value && !disabled && !readonly}
|
|
573
|
+
<button
|
|
574
|
+
type="button"
|
|
575
|
+
class="search-input-clear-button"
|
|
576
|
+
onclick={handleClear}
|
|
577
|
+
aria-label="Clear search"
|
|
578
|
+
tabindex={-1}
|
|
579
|
+
>
|
|
580
|
+
<X size={size === 'sm' ? 14 : size === 'lg' ? 18 : 16} />
|
|
581
|
+
</button>
|
|
582
|
+
{/if}
|
|
583
|
+
|
|
584
|
+
<button
|
|
585
|
+
type="button"
|
|
586
|
+
class="search-input-toggle-button"
|
|
587
|
+
onclick={handleToggleClick}
|
|
588
|
+
aria-label={isOpen ? 'Close suggestions' : 'Show suggestions'}
|
|
589
|
+
aria-expanded={isOpen}
|
|
590
|
+
tabindex={-1}
|
|
591
|
+
{disabled}
|
|
592
|
+
>
|
|
593
|
+
<ChevronDown
|
|
594
|
+
size={size === 'sm' ? 14 : size === 'lg' ? 18 : 16}
|
|
595
|
+
class={cn('search-input-chevron', isOpen && 'search-input-chevron-open')}
|
|
596
|
+
/>
|
|
597
|
+
</button>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
|
|
601
|
+
<!-- Listbox dropdown -->
|
|
602
|
+
{#if shouldShowDropdown}
|
|
603
|
+
<div
|
|
604
|
+
bind:this={listboxRef}
|
|
605
|
+
id={listboxId}
|
|
606
|
+
role="listbox"
|
|
607
|
+
aria-label={`${label} suggestions`}
|
|
608
|
+
class="search-input-listbox"
|
|
609
|
+
>
|
|
610
|
+
{#if loading}
|
|
611
|
+
<div class="search-input-loading" role="status" aria-live="polite">
|
|
612
|
+
{#if loadingContent}
|
|
613
|
+
{@render loadingContent()}
|
|
614
|
+
{:else}
|
|
615
|
+
<Loader2 size={20} class="search-input-spinner" />
|
|
616
|
+
<span>Searching...</span>
|
|
617
|
+
{/if}
|
|
618
|
+
</div>
|
|
619
|
+
{:else if showEmpty}
|
|
620
|
+
<div class="search-input-empty" role="status" aria-live="polite">
|
|
621
|
+
{#if emptyContent}
|
|
622
|
+
{@render emptyContent()}
|
|
623
|
+
{:else}
|
|
624
|
+
<span>{emptyMessage}</span>
|
|
625
|
+
{/if}
|
|
626
|
+
</div>
|
|
627
|
+
{:else if isGrouped}
|
|
628
|
+
{#each options as group, groupIndex}
|
|
629
|
+
{@const typedGroup = group as SearchOptionGroup}
|
|
630
|
+
<div class="search-input-group" role="group" aria-labelledby={`${id}-group-${groupIndex}`}>
|
|
631
|
+
<div id={`${id}-group-${groupIndex}`} class="search-input-group-label">
|
|
632
|
+
{typedGroup.label}
|
|
633
|
+
</div>
|
|
634
|
+
{#each typedGroup.options as option}
|
|
635
|
+
{@const optionIndex = getOptionIndex(option)}
|
|
636
|
+
{@const enabledIndex = enabledOptions.findIndex((o) => o.value === option.value)}
|
|
637
|
+
{@const isActive = enabledIndex === activeIndex}
|
|
638
|
+
{@const isSelected = selected?.value === option.value}
|
|
639
|
+
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
640
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
641
|
+
<div
|
|
642
|
+
id={`${id}-option-${optionIndex}`}
|
|
643
|
+
role="option"
|
|
644
|
+
aria-selected={isSelected}
|
|
645
|
+
aria-disabled={option.disabled}
|
|
646
|
+
data-index={optionIndex}
|
|
647
|
+
class={cn(
|
|
648
|
+
'search-input-option',
|
|
649
|
+
isActive && 'search-input-option-active',
|
|
650
|
+
isSelected && 'search-input-option-selected',
|
|
651
|
+
option.disabled && 'search-input-option-disabled'
|
|
652
|
+
)}
|
|
653
|
+
onclick={(e) => handleOptionClick(option, e)}
|
|
654
|
+
onmouseenter={() => handleOptionMouseEnter(optionIndex)}
|
|
655
|
+
>
|
|
656
|
+
{#if optionContent}
|
|
657
|
+
{@render optionContent(option)}
|
|
658
|
+
{:else}
|
|
659
|
+
<div class="search-input-option-content">
|
|
660
|
+
<span class="search-input-option-label">{option.label}</span>
|
|
661
|
+
{#if option.description}
|
|
662
|
+
<span class="search-input-option-description">{option.description}</span>
|
|
663
|
+
{/if}
|
|
664
|
+
</div>
|
|
665
|
+
{/if}
|
|
666
|
+
{#if isSelected}
|
|
667
|
+
<Check size={16} class="search-input-option-check" />
|
|
668
|
+
{/if}
|
|
669
|
+
</div>
|
|
670
|
+
{/each}
|
|
671
|
+
</div>
|
|
672
|
+
{/each}
|
|
673
|
+
{:else}
|
|
674
|
+
{#each flatOptions as option, index}
|
|
675
|
+
{@const enabledIndex = enabledOptions.findIndex((o) => o.value === option.value)}
|
|
676
|
+
{@const isActive = enabledIndex === activeIndex}
|
|
677
|
+
{@const isSelected = selected?.value === option.value}
|
|
678
|
+
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
679
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
680
|
+
<div
|
|
681
|
+
id={`${id}-option-${index}`}
|
|
682
|
+
role="option"
|
|
683
|
+
aria-selected={isSelected}
|
|
684
|
+
aria-disabled={option.disabled}
|
|
685
|
+
data-index={index}
|
|
686
|
+
class={cn(
|
|
687
|
+
'search-input-option',
|
|
688
|
+
isActive && 'search-input-option-active',
|
|
689
|
+
isSelected && 'search-input-option-selected',
|
|
690
|
+
option.disabled && 'search-input-option-disabled'
|
|
691
|
+
)}
|
|
692
|
+
onclick={(e) => handleOptionClick(option, e)}
|
|
693
|
+
onmouseenter={() => handleOptionMouseEnter(index)}
|
|
694
|
+
>
|
|
695
|
+
{#if optionContent}
|
|
696
|
+
{@render optionContent(option)}
|
|
697
|
+
{:else}
|
|
698
|
+
<div class="search-input-option-content">
|
|
699
|
+
<span class="search-input-option-label">{option.label}</span>
|
|
700
|
+
{#if option.description}
|
|
701
|
+
<span class="search-input-option-description">{option.description}</span>
|
|
702
|
+
{/if}
|
|
703
|
+
</div>
|
|
704
|
+
{/if}
|
|
705
|
+
{#if isSelected}
|
|
706
|
+
<Check size={16} class="search-input-option-check" />
|
|
707
|
+
{/if}
|
|
708
|
+
</div>
|
|
709
|
+
{/each}
|
|
710
|
+
{/if}
|
|
711
|
+
</div>
|
|
712
|
+
{/if}
|
|
713
|
+
</div>
|
|
714
|
+
|
|
715
|
+
<!-- Screen reader announcements for list count -->
|
|
716
|
+
{#if isOpen && !loading && flatOptions.length > 0}
|
|
717
|
+
<div class="sr-only" role="status" aria-live="polite" aria-atomic="true">
|
|
718
|
+
{flatOptions.length} suggestion{flatOptions.length === 1 ? '' : 's'} available
|
|
719
|
+
</div>
|
|
720
|
+
{/if}
|
|
721
|
+
|
|
722
|
+
<!-- Footer messages -->
|
|
723
|
+
<div class="search-input-footer">
|
|
724
|
+
<div class="search-input-messages">
|
|
725
|
+
{#if showError}
|
|
726
|
+
<p id={errorId} class="search-input-message search-input-message-error" role="alert" aria-live="assertive">
|
|
727
|
+
{error}
|
|
728
|
+
</p>
|
|
729
|
+
{:else if showSuccess}
|
|
730
|
+
<p class="search-input-message search-input-message-success">
|
|
731
|
+
{successMessage}
|
|
732
|
+
</p>
|
|
733
|
+
{:else if hint && isFocused}
|
|
734
|
+
<p id={hintId} class="search-input-message search-input-message-hint">
|
|
735
|
+
{hint}
|
|
736
|
+
</p>
|
|
737
|
+
{/if}
|
|
738
|
+
</div>
|
|
739
|
+
</div>
|
|
740
|
+
</div>
|
|
741
|
+
|
|
742
|
+
<style>
|
|
743
|
+
/* ========================================
|
|
744
|
+
BASE WRAPPER
|
|
745
|
+
======================================== */
|
|
746
|
+
.search-input-wrapper {
|
|
747
|
+
display: flex;
|
|
748
|
+
flex-direction: column;
|
|
749
|
+
gap: 0.375rem;
|
|
750
|
+
position: relative;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
.search-input-disabled {
|
|
754
|
+
opacity: 0.6;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/* ========================================
|
|
758
|
+
LABEL
|
|
759
|
+
======================================== */
|
|
760
|
+
.search-input-label {
|
|
761
|
+
display: flex;
|
|
762
|
+
align-items: center;
|
|
763
|
+
gap: 0.375rem;
|
|
764
|
+
font-weight: 500;
|
|
765
|
+
color: var(--ui-text-primary);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
.search-input-sm .search-input-label {
|
|
769
|
+
font-size: 0.75rem;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
.search-input-md .search-input-label,
|
|
773
|
+
.search-input-lg .search-input-label {
|
|
774
|
+
font-size: 0.875rem;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
.search-input-required {
|
|
778
|
+
color: rgb(var(--ui-color-error));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
:global([data-theme='dark']) .search-input-required {
|
|
782
|
+
color: rgb(var(--ui-color-error-light));
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
@media (prefers-color-scheme: dark) {
|
|
786
|
+
:global(:root:not([data-theme='light'])) .search-input-required {
|
|
787
|
+
color: rgb(var(--ui-color-error-light));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
.search-input-hint-icon {
|
|
792
|
+
color: var(--ui-text-tertiary);
|
|
793
|
+
cursor: help;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/* ========================================
|
|
797
|
+
CONTAINER
|
|
798
|
+
======================================== */
|
|
799
|
+
.search-input-container {
|
|
800
|
+
position: relative;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/* ========================================
|
|
804
|
+
FIELD WRAPPER
|
|
805
|
+
======================================== */
|
|
806
|
+
.search-input-field-wrapper {
|
|
807
|
+
position: relative;
|
|
808
|
+
display: flex;
|
|
809
|
+
align-items: center;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/* ========================================
|
|
813
|
+
INPUT FIELD
|
|
814
|
+
======================================== */
|
|
815
|
+
.search-input-field {
|
|
816
|
+
width: 100%;
|
|
817
|
+
border: 2px solid var(--ui-border-default);
|
|
818
|
+
border-radius: 0.5rem;
|
|
819
|
+
background: var(--ui-bg-secondary);
|
|
820
|
+
color: var(--ui-text-primary);
|
|
821
|
+
transition: all 0.2s ease;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
.search-input-field::placeholder {
|
|
825
|
+
color: var(--ui-text-tertiary);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
.search-input-field:focus-visible {
|
|
829
|
+
outline: none;
|
|
830
|
+
background: var(--ui-bg-primary);
|
|
831
|
+
border-color: rgb(var(--ui-color-primary));
|
|
832
|
+
box-shadow: 0 0 0 3px rgb(var(--ui-color-primary) / 0.4);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
:global([data-theme='dark']) .search-input-field:focus-visible {
|
|
836
|
+
border-color: rgb(var(--ui-color-primary-light));
|
|
837
|
+
box-shadow: 0 0 0 3px rgb(var(--ui-color-primary-light) / 0.5);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
@media (prefers-color-scheme: dark) {
|
|
841
|
+
:global(:root:not([data-theme='light'])) .search-input-field:focus-visible {
|
|
842
|
+
border-color: rgb(var(--ui-color-primary-light));
|
|
843
|
+
box-shadow: 0 0 0 3px rgb(var(--ui-color-primary-light) / 0.5);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
.search-input-field:disabled {
|
|
848
|
+
cursor: not-allowed;
|
|
849
|
+
background: var(--ui-bg-tertiary);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
.search-input-field:read-only {
|
|
853
|
+
background: var(--ui-bg-tertiary);
|
|
854
|
+
cursor: default;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/* Size variants */
|
|
858
|
+
.search-input-sm .search-input-field {
|
|
859
|
+
height: 2rem;
|
|
860
|
+
padding: 0 0.75rem;
|
|
861
|
+
font-size: 0.875rem;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
.search-input-md .search-input-field {
|
|
865
|
+
height: 2.5rem;
|
|
866
|
+
padding: 0 0.75rem;
|
|
867
|
+
font-size: 0.875rem;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
.search-input-lg .search-input-field {
|
|
871
|
+
height: 3rem;
|
|
872
|
+
padding: 0 1rem;
|
|
873
|
+
font-size: 1rem;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/* Icon padding adjustments */
|
|
877
|
+
.search-input-field.search-input-has-icon-left {
|
|
878
|
+
padding-left: 2.5rem !important;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
.search-input-field.search-input-has-icon-right {
|
|
882
|
+
padding-right: 5rem !important;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/* Error state */
|
|
886
|
+
.search-input-error {
|
|
887
|
+
border-color: rgb(var(--ui-color-error));
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
.search-input-error:focus-visible {
|
|
891
|
+
border-color: rgb(var(--ui-color-error));
|
|
892
|
+
box-shadow: 0 0 0 3px rgb(var(--ui-color-error) / 0.4);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
:global([data-theme='dark']) .search-input-error {
|
|
896
|
+
border-color: rgb(var(--ui-color-error-light));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
:global([data-theme='dark']) .search-input-error:focus-visible {
|
|
900
|
+
border-color: rgb(var(--ui-color-error-light));
|
|
901
|
+
box-shadow: 0 0 0 3px rgb(var(--ui-color-error-light) / 0.5);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
@media (prefers-color-scheme: dark) {
|
|
905
|
+
:global(:root:not([data-theme='light'])) .search-input-error {
|
|
906
|
+
border-color: rgb(var(--ui-color-error-light));
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
:global(:root:not([data-theme='light'])) .search-input-error:focus-visible {
|
|
910
|
+
border-color: rgb(var(--ui-color-error-light));
|
|
911
|
+
box-shadow: 0 0 0 3px rgb(var(--ui-color-error-light) / 0.5);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/* Valid state */
|
|
916
|
+
.search-input-valid {
|
|
917
|
+
border-color: rgb(var(--ui-color-success));
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
.search-input-valid:focus-visible {
|
|
921
|
+
border-color: rgb(var(--ui-color-success));
|
|
922
|
+
box-shadow: 0 0 0 3px rgb(var(--ui-color-success) / 0.4);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
:global([data-theme='dark']) .search-input-valid {
|
|
926
|
+
border-color: rgb(var(--ui-color-success-light));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
:global([data-theme='dark']) .search-input-valid:focus-visible {
|
|
930
|
+
border-color: rgb(var(--ui-color-success-light));
|
|
931
|
+
box-shadow: 0 0 0 3px rgb(var(--ui-color-success-light) / 0.5);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
@media (prefers-color-scheme: dark) {
|
|
935
|
+
:global(:root:not([data-theme='light'])) .search-input-valid {
|
|
936
|
+
border-color: rgb(var(--ui-color-success-light));
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
:global(:root:not([data-theme='light'])) .search-input-valid:focus-visible {
|
|
940
|
+
border-color: rgb(var(--ui-color-success-light));
|
|
941
|
+
box-shadow: 0 0 0 3px rgb(var(--ui-color-success-light) / 0.5);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/* ========================================
|
|
946
|
+
ICONS
|
|
947
|
+
======================================== */
|
|
948
|
+
.search-input-icon-left {
|
|
949
|
+
position: absolute;
|
|
950
|
+
left: 0.75rem;
|
|
951
|
+
top: 50%;
|
|
952
|
+
transform: translateY(-50%);
|
|
953
|
+
z-index: 1;
|
|
954
|
+
pointer-events: none;
|
|
955
|
+
color: var(--ui-text-tertiary);
|
|
956
|
+
transition: color 0.2s ease;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
.search-input-icon-focused {
|
|
960
|
+
color: rgb(var(--ui-color-primary));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
:global([data-theme='dark']) .search-input-icon-focused {
|
|
964
|
+
color: rgb(var(--ui-color-primary-light));
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
@media (prefers-color-scheme: dark) {
|
|
968
|
+
:global(:root:not([data-theme='light'])) .search-input-icon-focused {
|
|
969
|
+
color: rgb(var(--ui-color-primary-light));
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
.search-input-icons-right {
|
|
974
|
+
position: absolute;
|
|
975
|
+
right: 0.5rem;
|
|
976
|
+
top: 50%;
|
|
977
|
+
transform: translateY(-50%);
|
|
978
|
+
display: flex;
|
|
979
|
+
align-items: center;
|
|
980
|
+
gap: 0.25rem;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
.search-input-icon-loading,
|
|
984
|
+
.search-input-icon-error,
|
|
985
|
+
.search-input-icon-success {
|
|
986
|
+
display: flex;
|
|
987
|
+
align-items: center;
|
|
988
|
+
justify-content: center;
|
|
989
|
+
color: var(--ui-text-tertiary);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
.search-input-icon-error {
|
|
993
|
+
color: rgb(var(--ui-color-error));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
:global([data-theme='dark']) .search-input-icon-error {
|
|
997
|
+
color: rgb(var(--ui-color-error-light));
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
@media (prefers-color-scheme: dark) {
|
|
1001
|
+
:global(:root:not([data-theme='light'])) .search-input-icon-error {
|
|
1002
|
+
color: rgb(var(--ui-color-error-light));
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
.search-input-icon-success {
|
|
1007
|
+
color: rgb(var(--ui-color-success));
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
:global([data-theme='dark']) .search-input-icon-success {
|
|
1011
|
+
color: rgb(var(--ui-color-success-light));
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
@media (prefers-color-scheme: dark) {
|
|
1015
|
+
:global(:root:not([data-theme='light'])) .search-input-icon-success {
|
|
1016
|
+
color: rgb(var(--ui-color-success-light));
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
.search-input-spinner {
|
|
1021
|
+
animation: spin 1s linear infinite;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
@keyframes spin {
|
|
1025
|
+
from {
|
|
1026
|
+
transform: rotate(0deg);
|
|
1027
|
+
}
|
|
1028
|
+
to {
|
|
1029
|
+
transform: rotate(360deg);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1034
|
+
.search-input-spinner {
|
|
1035
|
+
animation: none;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
.search-input-clear-button,
|
|
1040
|
+
.search-input-toggle-button {
|
|
1041
|
+
display: flex;
|
|
1042
|
+
align-items: center;
|
|
1043
|
+
justify-content: center;
|
|
1044
|
+
padding: 0.25rem;
|
|
1045
|
+
border: none;
|
|
1046
|
+
background: transparent;
|
|
1047
|
+
color: var(--ui-text-tertiary);
|
|
1048
|
+
cursor: pointer;
|
|
1049
|
+
border-radius: 0.25rem;
|
|
1050
|
+
transition: all 0.15s ease;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
.search-input-clear-button:hover,
|
|
1054
|
+
.search-input-toggle-button:hover {
|
|
1055
|
+
color: var(--ui-text-primary);
|
|
1056
|
+
background: var(--ui-bg-tertiary);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
.search-input-clear-button:focus-visible,
|
|
1060
|
+
.search-input-toggle-button:focus-visible {
|
|
1061
|
+
outline: 2px solid rgb(var(--ui-color-primary));
|
|
1062
|
+
outline-offset: 1px;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
.search-input-toggle-button:disabled {
|
|
1066
|
+
cursor: not-allowed;
|
|
1067
|
+
opacity: 0.5;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
.search-input-chevron {
|
|
1071
|
+
transition: transform 0.2s ease;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
.search-input-chevron-open {
|
|
1075
|
+
transform: rotate(180deg);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/* ========================================
|
|
1079
|
+
LISTBOX DROPDOWN
|
|
1080
|
+
======================================== */
|
|
1081
|
+
.search-input-listbox {
|
|
1082
|
+
position: absolute;
|
|
1083
|
+
top: 100%;
|
|
1084
|
+
left: 0;
|
|
1085
|
+
right: 0;
|
|
1086
|
+
z-index: 50;
|
|
1087
|
+
margin-top: 0.25rem;
|
|
1088
|
+
max-height: 16rem;
|
|
1089
|
+
overflow-y: auto;
|
|
1090
|
+
background: var(--ui-bg-primary);
|
|
1091
|
+
border: 2px solid var(--ui-border-default);
|
|
1092
|
+
border-radius: 0.5rem;
|
|
1093
|
+
box-shadow: var(--ui-shadow-lg);
|
|
1094
|
+
animation: slideDown 0.15s ease-out;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
@keyframes slideDown {
|
|
1098
|
+
from {
|
|
1099
|
+
opacity: 0;
|
|
1100
|
+
transform: translateY(-4px);
|
|
1101
|
+
}
|
|
1102
|
+
to {
|
|
1103
|
+
opacity: 1;
|
|
1104
|
+
transform: translateY(0);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1109
|
+
.search-input-listbox {
|
|
1110
|
+
animation: none;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/* ========================================
|
|
1115
|
+
OPTION ITEMS
|
|
1116
|
+
======================================== */
|
|
1117
|
+
.search-input-option {
|
|
1118
|
+
display: flex;
|
|
1119
|
+
align-items: center;
|
|
1120
|
+
justify-content: space-between;
|
|
1121
|
+
padding: 0.625rem 0.75rem;
|
|
1122
|
+
cursor: pointer;
|
|
1123
|
+
transition: background-color 0.1s ease;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
.search-input-option:hover,
|
|
1127
|
+
.search-input-option-active {
|
|
1128
|
+
background: var(--ui-bg-secondary);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
.search-input-option-selected {
|
|
1132
|
+
background: rgb(var(--ui-color-primary) / 0.1);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
:global([data-theme='dark']) .search-input-option-selected {
|
|
1136
|
+
background: rgb(var(--ui-color-primary-light) / 0.15);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
@media (prefers-color-scheme: dark) {
|
|
1140
|
+
:global(:root:not([data-theme='light'])) .search-input-option-selected {
|
|
1141
|
+
background: rgb(var(--ui-color-primary-light) / 0.15);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
.search-input-option-active.search-input-option-selected {
|
|
1146
|
+
background: rgb(var(--ui-color-primary) / 0.15);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
:global([data-theme='dark']) .search-input-option-active.search-input-option-selected {
|
|
1150
|
+
background: rgb(var(--ui-color-primary-light) / 0.2);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
@media (prefers-color-scheme: dark) {
|
|
1154
|
+
:global(:root:not([data-theme='light'])) .search-input-option-active.search-input-option-selected {
|
|
1155
|
+
background: rgb(var(--ui-color-primary-light) / 0.2);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
.search-input-option-disabled {
|
|
1160
|
+
opacity: 0.5;
|
|
1161
|
+
cursor: not-allowed;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
.search-input-option-disabled:hover {
|
|
1165
|
+
background: transparent;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
.search-input-option-content {
|
|
1169
|
+
display: flex;
|
|
1170
|
+
flex-direction: column;
|
|
1171
|
+
gap: 0.125rem;
|
|
1172
|
+
min-width: 0;
|
|
1173
|
+
flex: 1;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
.search-input-option-label {
|
|
1177
|
+
font-weight: 500;
|
|
1178
|
+
color: var(--ui-text-primary);
|
|
1179
|
+
white-space: nowrap;
|
|
1180
|
+
overflow: hidden;
|
|
1181
|
+
text-overflow: ellipsis;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
.search-input-option-description {
|
|
1185
|
+
font-size: 0.75rem;
|
|
1186
|
+
color: var(--ui-text-tertiary);
|
|
1187
|
+
white-space: nowrap;
|
|
1188
|
+
overflow: hidden;
|
|
1189
|
+
text-overflow: ellipsis;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
.search-input-option-check {
|
|
1193
|
+
flex-shrink: 0;
|
|
1194
|
+
color: rgb(var(--ui-color-primary));
|
|
1195
|
+
margin-left: 0.5rem;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
:global([data-theme='dark']) .search-input-option-check {
|
|
1199
|
+
color: rgb(var(--ui-color-primary-light));
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
@media (prefers-color-scheme: dark) {
|
|
1203
|
+
:global(:root:not([data-theme='light'])) .search-input-option-check {
|
|
1204
|
+
color: rgb(var(--ui-color-primary-light));
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/* ========================================
|
|
1209
|
+
GROUPS
|
|
1210
|
+
======================================== */
|
|
1211
|
+
.search-input-group {
|
|
1212
|
+
padding-top: 0.25rem;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
.search-input-group:not(:first-child) {
|
|
1216
|
+
border-top: 1px solid var(--ui-border-default);
|
|
1217
|
+
margin-top: 0.25rem;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
.search-input-group-label {
|
|
1221
|
+
padding: 0.5rem 0.75rem 0.25rem;
|
|
1222
|
+
font-size: 0.6875rem;
|
|
1223
|
+
font-weight: 600;
|
|
1224
|
+
text-transform: uppercase;
|
|
1225
|
+
letter-spacing: 0.05em;
|
|
1226
|
+
color: var(--ui-text-tertiary);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/* ========================================
|
|
1230
|
+
LOADING & EMPTY STATES
|
|
1231
|
+
======================================== */
|
|
1232
|
+
.search-input-loading,
|
|
1233
|
+
.search-input-empty {
|
|
1234
|
+
display: flex;
|
|
1235
|
+
align-items: center;
|
|
1236
|
+
justify-content: center;
|
|
1237
|
+
gap: 0.5rem;
|
|
1238
|
+
padding: 1.5rem;
|
|
1239
|
+
color: var(--ui-text-tertiary);
|
|
1240
|
+
font-size: 0.875rem;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/* ========================================
|
|
1244
|
+
FOOTER & MESSAGES
|
|
1245
|
+
======================================== */
|
|
1246
|
+
.search-input-footer {
|
|
1247
|
+
display: flex;
|
|
1248
|
+
justify-content: space-between;
|
|
1249
|
+
align-items: center;
|
|
1250
|
+
min-height: 1.25rem;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
.search-input-messages {
|
|
1254
|
+
flex: 1;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
.search-input-message {
|
|
1258
|
+
font-size: 0.75rem;
|
|
1259
|
+
animation: slideIn 0.2s ease-out;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
@keyframes slideIn {
|
|
1263
|
+
from {
|
|
1264
|
+
opacity: 0;
|
|
1265
|
+
transform: translateY(-4px);
|
|
1266
|
+
}
|
|
1267
|
+
to {
|
|
1268
|
+
opacity: 1;
|
|
1269
|
+
transform: translateY(0);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
.search-input-message-error {
|
|
1274
|
+
color: rgb(var(--ui-color-error));
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
:global([data-theme='dark']) .search-input-message-error {
|
|
1278
|
+
color: rgb(var(--ui-color-error-light));
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
@media (prefers-color-scheme: dark) {
|
|
1282
|
+
:global(:root:not([data-theme='light'])) .search-input-message-error {
|
|
1283
|
+
color: rgb(var(--ui-color-error-light));
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
.search-input-message-success {
|
|
1288
|
+
color: rgb(var(--ui-color-success));
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
:global([data-theme='dark']) .search-input-message-success {
|
|
1292
|
+
color: rgb(var(--ui-color-success-light));
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
@media (prefers-color-scheme: dark) {
|
|
1296
|
+
:global(:root:not([data-theme='light'])) .search-input-message-success {
|
|
1297
|
+
color: rgb(var(--ui-color-success-light));
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
.search-input-message-hint {
|
|
1302
|
+
color: var(--ui-text-tertiary);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/* ========================================
|
|
1306
|
+
UTILITIES
|
|
1307
|
+
======================================== */
|
|
1308
|
+
.sr-only {
|
|
1309
|
+
position: absolute;
|
|
1310
|
+
width: 1px;
|
|
1311
|
+
height: 1px;
|
|
1312
|
+
padding: 0;
|
|
1313
|
+
margin: -1px;
|
|
1314
|
+
overflow: hidden;
|
|
1315
|
+
clip: rect(0, 0, 0, 0);
|
|
1316
|
+
white-space: nowrap;
|
|
1317
|
+
border: 0;
|
|
1318
|
+
}
|
|
1319
|
+
</style>
|