@salmexio/ui 1.2.1 → 1.3.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/dist/feedback/Alert/Alert.svelte +4 -1
- package/dist/feedback/Alert/Alert.svelte.d.ts +1 -0
- package/dist/feedback/Alert/Alert.svelte.d.ts.map +1 -1
- package/dist/feedback/Spinner/Spinner.svelte +4 -1
- package/dist/feedback/Spinner/Spinner.svelte.d.ts +1 -0
- package/dist/feedback/Spinner/Spinner.svelte.d.ts.map +1 -1
- package/dist/forms/DatePicker/DatePicker.svelte +725 -0
- package/dist/forms/DatePicker/DatePicker.svelte.d.ts +48 -0
- package/dist/forms/DatePicker/DatePicker.svelte.d.ts.map +1 -0
- package/dist/forms/DatePicker/index.d.ts +2 -0
- package/dist/forms/DatePicker/index.d.ts.map +1 -0
- package/dist/forms/DatePicker/index.js +1 -0
- package/dist/forms/FormField/FormField.svelte +173 -0
- package/dist/forms/FormField/FormField.svelte.d.ts +46 -0
- package/dist/forms/FormField/FormField.svelte.d.ts.map +1 -0
- package/dist/forms/FormField/index.d.ts +2 -0
- package/dist/forms/FormField/index.d.ts.map +1 -0
- package/dist/forms/FormField/index.js +1 -0
- package/dist/forms/MultiSelect/MultiSelect.svelte +820 -0
- package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts +69 -0
- package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts.map +1 -0
- package/dist/forms/MultiSelect/index.d.ts +3 -0
- package/dist/forms/MultiSelect/index.d.ts.map +1 -0
- package/dist/forms/MultiSelect/index.js +1 -0
- package/dist/forms/PhoneInput/PhoneInput.svelte +591 -0
- package/dist/forms/PhoneInput/PhoneInput.svelte.d.ts +57 -0
- package/dist/forms/PhoneInput/PhoneInput.svelte.d.ts.map +1 -0
- package/dist/forms/PhoneInput/index.d.ts +4 -0
- package/dist/forms/PhoneInput/index.d.ts.map +1 -0
- package/dist/forms/PhoneInput/index.js +2 -0
- package/dist/forms/RadioGroup/RadioGroup.svelte +417 -0
- package/dist/forms/RadioGroup/RadioGroup.svelte.d.ts +62 -0
- package/dist/forms/RadioGroup/RadioGroup.svelte.d.ts.map +1 -0
- package/dist/forms/RadioGroup/index.d.ts +3 -0
- package/dist/forms/RadioGroup/index.d.ts.map +1 -0
- package/dist/forms/RadioGroup/index.js +1 -0
- package/dist/forms/SearchInput/SearchInput.svelte +788 -0
- package/dist/forms/SearchInput/SearchInput.svelte.d.ts +79 -0
- package/dist/forms/SearchInput/SearchInput.svelte.d.ts.map +1 -0
- package/dist/forms/SearchInput/index.d.ts +3 -0
- package/dist/forms/SearchInput/index.d.ts.map +1 -0
- package/dist/forms/SearchInput/index.js +1 -0
- package/dist/forms/Select/Select.svelte +14 -8
- package/dist/forms/Select/Select.svelte.d.ts +2 -0
- package/dist/forms/Select/Select.svelte.d.ts.map +1 -1
- package/dist/forms/TextInput/TextInput.svelte +38 -16
- package/dist/forms/TextInput/TextInput.svelte.d.ts +6 -0
- package/dist/forms/TextInput/TextInput.svelte.d.ts.map +1 -1
- package/dist/forms/Textarea/Textarea.svelte +7 -1
- package/dist/forms/Textarea/Textarea.svelte.d.ts +2 -0
- package/dist/forms/Textarea/Textarea.svelte.d.ts.map +1 -1
- package/dist/forms/TimePicker/TimePicker.svelte +417 -0
- package/dist/forms/TimePicker/TimePicker.svelte.d.ts +53 -0
- package/dist/forms/TimePicker/TimePicker.svelte.d.ts.map +1 -0
- package/dist/forms/TimePicker/index.d.ts +2 -0
- package/dist/forms/TimePicker/index.d.ts.map +1 -0
- package/dist/forms/TimePicker/index.js +1 -0
- package/dist/forms/index.d.ts +12 -0
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/index.js +8 -0
- package/dist/layout/Container/Container.svelte +3 -0
- package/dist/layout/Container/Container.svelte.d.ts +1 -0
- package/dist/layout/Container/Container.svelte.d.ts.map +1 -1
- package/dist/primitives/Badge/Badge.svelte +5 -1
- package/dist/primitives/Badge/Badge.svelte.d.ts +1 -0
- package/dist/primitives/Badge/Badge.svelte.d.ts.map +1 -1
- package/dist/primitives/Tooltip/Tooltip.svelte +7 -1
- package/dist/primitives/Tooltip/Tooltip.svelte.d.ts.map +1 -1
- package/dist/utils/accessibility.d.ts +16 -0
- package/dist/utils/accessibility.d.ts.map +1 -0
- package/dist/utils/accessibility.js +80 -0
- package/dist/utils/index.d.ts +2 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -1
- package/dist/utils/keyboard.d.ts +6 -0
- package/dist/utils/keyboard.d.ts.map +1 -1
- package/dist/utils/keyboard.js +15 -9
- package/package.json +21 -1
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component SearchInput
|
|
3
|
+
|
|
4
|
+
INFRARED — Search input with autocomplete dropdown, match highlighting,
|
|
5
|
+
grouped results, loading state, and keyboard navigation.
|
|
6
|
+
Follows WAI-ARIA combobox pattern.
|
|
7
|
+
|
|
8
|
+
@example
|
|
9
|
+
<SearchInput
|
|
10
|
+
label="Search models"
|
|
11
|
+
options={[
|
|
12
|
+
{ value: 'opus', label: 'Claude Opus' },
|
|
13
|
+
{ value: 'sonnet', label: 'Claude Sonnet' },
|
|
14
|
+
]}
|
|
15
|
+
bind:value={query}
|
|
16
|
+
onselect={(opt) => console.log(opt)}
|
|
17
|
+
/>
|
|
18
|
+
-->
|
|
19
|
+
<script lang="ts" module>
|
|
20
|
+
export interface SearchOption {
|
|
21
|
+
value: string;
|
|
22
|
+
label: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SearchGroup {
|
|
28
|
+
label: string;
|
|
29
|
+
options: SearchOption[];
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<script lang="ts">
|
|
34
|
+
import { cn } from '../../utils/cn.js';
|
|
35
|
+
import { Keys, generateId } from '../../utils/keyboard.js';
|
|
36
|
+
import { onMount, tick } from 'svelte';
|
|
37
|
+
|
|
38
|
+
type SearchSize = 'sm' | 'md' | 'lg';
|
|
39
|
+
|
|
40
|
+
interface Props {
|
|
41
|
+
/** Visible label. */
|
|
42
|
+
label: string;
|
|
43
|
+
/** Search query value (bindable). */
|
|
44
|
+
value?: string;
|
|
45
|
+
/** Flat list of options. */
|
|
46
|
+
options?: SearchOption[];
|
|
47
|
+
/** Grouped options. */
|
|
48
|
+
groups?: SearchGroup[];
|
|
49
|
+
/** Placeholder text. */
|
|
50
|
+
placeholder?: string;
|
|
51
|
+
/** Minimum characters before showing suggestions. */
|
|
52
|
+
minChars?: number;
|
|
53
|
+
/** Debounce delay in ms for the onsearch callback. */
|
|
54
|
+
debounceMs?: number;
|
|
55
|
+
/** Show loading spinner in dropdown. */
|
|
56
|
+
loading?: boolean;
|
|
57
|
+
/** Allow clearing the input. */
|
|
58
|
+
clearable?: boolean;
|
|
59
|
+
/** Size variant. */
|
|
60
|
+
size?: SearchSize;
|
|
61
|
+
/** Error message. */
|
|
62
|
+
error?: string;
|
|
63
|
+
/** Hint text. */
|
|
64
|
+
hint?: string;
|
|
65
|
+
/** Disabled state. */
|
|
66
|
+
disabled?: boolean;
|
|
67
|
+
/** Required field. */
|
|
68
|
+
required?: boolean;
|
|
69
|
+
/** Hide the visible label. */
|
|
70
|
+
hideLabel?: boolean;
|
|
71
|
+
/** Reserve footer space even when empty. */
|
|
72
|
+
alwaysShowFooter?: boolean;
|
|
73
|
+
/** Additional CSS class. */
|
|
74
|
+
class?: string;
|
|
75
|
+
/** Called when an option is selected. */
|
|
76
|
+
onselect?: (option: SearchOption) => void;
|
|
77
|
+
/** Called when search query changes (debounced). */
|
|
78
|
+
onsearch?: (query: string) => void;
|
|
79
|
+
/** Called when input value changes. */
|
|
80
|
+
oninput?: (event: Event) => void;
|
|
81
|
+
/** Called when input loses focus. */
|
|
82
|
+
onblur?: (event: FocusEvent) => void;
|
|
83
|
+
/** Test ID. */
|
|
84
|
+
testId?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let {
|
|
88
|
+
label,
|
|
89
|
+
value = $bindable(''),
|
|
90
|
+
options = [],
|
|
91
|
+
groups = [],
|
|
92
|
+
placeholder = 'Search...',
|
|
93
|
+
minChars = 1,
|
|
94
|
+
debounceMs = 250,
|
|
95
|
+
loading = false,
|
|
96
|
+
clearable = true,
|
|
97
|
+
size = 'md',
|
|
98
|
+
error = '',
|
|
99
|
+
hint = '',
|
|
100
|
+
disabled = false,
|
|
101
|
+
required = false,
|
|
102
|
+
hideLabel = false,
|
|
103
|
+
alwaysShowFooter = true,
|
|
104
|
+
class: className = '',
|
|
105
|
+
onselect,
|
|
106
|
+
onsearch,
|
|
107
|
+
oninput,
|
|
108
|
+
onblur,
|
|
109
|
+
testId
|
|
110
|
+
}: Props = $props();
|
|
111
|
+
|
|
112
|
+
const id = generateId('search');
|
|
113
|
+
const listboxId = `${id}-listbox`;
|
|
114
|
+
const labelId = `${id}-label`;
|
|
115
|
+
const errorId = `${id}-error`;
|
|
116
|
+
const hintId = `${id}-hint`;
|
|
117
|
+
|
|
118
|
+
let inputRef = $state<HTMLInputElement | null>(null);
|
|
119
|
+
let listboxEl = $state<HTMLDivElement | null>(null);
|
|
120
|
+
let isOpen = $state(false);
|
|
121
|
+
let activeIndex = $state(-1);
|
|
122
|
+
let isFocused = $state(false);
|
|
123
|
+
let hasInteracted = $state(false);
|
|
124
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
125
|
+
let blurTimer: ReturnType<typeof setTimeout> | undefined;
|
|
126
|
+
|
|
127
|
+
// Panel positioning
|
|
128
|
+
let panelTop = $state(0);
|
|
129
|
+
let panelLeft = $state(0);
|
|
130
|
+
let panelWidth = $state(0);
|
|
131
|
+
let panelMaxHeight = $state(260);
|
|
132
|
+
let placeAbove = $state(false);
|
|
133
|
+
|
|
134
|
+
// Flatten all options
|
|
135
|
+
const flatOptions = $derived<SearchOption[]>(
|
|
136
|
+
groups.length > 0
|
|
137
|
+
? groups.flatMap((g) => g.options)
|
|
138
|
+
: options
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Filter based on query
|
|
142
|
+
const filteredFlat = $derived<SearchOption[]>(
|
|
143
|
+
value.length >= minChars
|
|
144
|
+
? flatOptions.filter((o) =>
|
|
145
|
+
o.label.toLowerCase().includes(value.toLowerCase())
|
|
146
|
+
)
|
|
147
|
+
: []
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const filteredGroups = $derived(
|
|
151
|
+
groups.length > 0 && value.length >= minChars
|
|
152
|
+
? groups
|
|
153
|
+
.map((g) => ({
|
|
154
|
+
...g,
|
|
155
|
+
options: g.options.filter((o) =>
|
|
156
|
+
o.label.toLowerCase().includes(value.toLowerCase())
|
|
157
|
+
),
|
|
158
|
+
}))
|
|
159
|
+
.filter((g) => g.options.length > 0)
|
|
160
|
+
: []
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const displayOptions = $derived<SearchOption[]>(
|
|
164
|
+
groups.length > 0
|
|
165
|
+
? filteredGroups.flatMap((g) => g.options)
|
|
166
|
+
: filteredFlat
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const enabledIndices = $derived(
|
|
170
|
+
displayOptions
|
|
171
|
+
.map((o, i) => ({ i, disabled: o.disabled }))
|
|
172
|
+
.filter((x) => !x.disabled)
|
|
173
|
+
.map((x) => x.i)
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const showError = $derived(hasInteracted && !!error);
|
|
177
|
+
const shouldOpen = $derived(
|
|
178
|
+
isFocused && value.length >= minChars && (displayOptions.length > 0 || loading)
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const ariaDescribedBy = $derived(
|
|
182
|
+
[showError && errorId, hint && hintId].filter(Boolean).join(' ') || undefined
|
|
183
|
+
);
|
|
184
|
+
const hasFooterContent = $derived(showError || !!hint);
|
|
185
|
+
|
|
186
|
+
$effect(() => {
|
|
187
|
+
if (shouldOpen && !isOpen) {
|
|
188
|
+
isOpen = true;
|
|
189
|
+
positionDropdown();
|
|
190
|
+
} else if (!shouldOpen && isOpen) {
|
|
191
|
+
isOpen = false;
|
|
192
|
+
activeIndex = -1;
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
function positionDropdown() {
|
|
197
|
+
if (!inputRef) return;
|
|
198
|
+
const rect = inputRef.getBoundingClientRect();
|
|
199
|
+
const viewportH = window.innerHeight;
|
|
200
|
+
const spaceBelow = viewportH - rect.bottom;
|
|
201
|
+
const spaceAbove = rect.top;
|
|
202
|
+
const maxH = 260;
|
|
203
|
+
|
|
204
|
+
placeAbove = spaceBelow < Math.min(maxH, 150) && spaceAbove > spaceBelow;
|
|
205
|
+
panelLeft = rect.left;
|
|
206
|
+
panelWidth = rect.width;
|
|
207
|
+
|
|
208
|
+
if (placeAbove) {
|
|
209
|
+
panelMaxHeight = Math.min(maxH, spaceAbove - 8);
|
|
210
|
+
panelTop = rect.top - 2;
|
|
211
|
+
} else {
|
|
212
|
+
panelMaxHeight = Math.min(maxH, spaceBelow - 8);
|
|
213
|
+
panelTop = rect.bottom + 2;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function handleInput(e: Event) {
|
|
218
|
+
const target = e.target as HTMLInputElement;
|
|
219
|
+
value = target.value;
|
|
220
|
+
activeIndex = -1;
|
|
221
|
+
oninput?.(e);
|
|
222
|
+
|
|
223
|
+
if (debounceMs && onsearch) {
|
|
224
|
+
clearTimeout(debounceTimer);
|
|
225
|
+
debounceTimer = setTimeout(() => onsearch!(value), debounceMs);
|
|
226
|
+
} else {
|
|
227
|
+
onsearch?.(value);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function handleFocus() {
|
|
232
|
+
isFocused = true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function handleBlur(e: FocusEvent) {
|
|
236
|
+
// Delay to allow click on options
|
|
237
|
+
blurTimer = setTimeout(() => {
|
|
238
|
+
isFocused = false;
|
|
239
|
+
hasInteracted = true;
|
|
240
|
+
onblur?.(e);
|
|
241
|
+
}, 150);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function selectOption(opt: SearchOption) {
|
|
245
|
+
if (opt.disabled) return;
|
|
246
|
+
value = opt.label;
|
|
247
|
+
isOpen = false;
|
|
248
|
+
activeIndex = -1;
|
|
249
|
+
onselect?.(opt);
|
|
250
|
+
inputRef?.focus();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function handleClear() {
|
|
254
|
+
value = '';
|
|
255
|
+
activeIndex = -1;
|
|
256
|
+
onsearch?.('');
|
|
257
|
+
inputRef?.focus();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function scrollActiveIntoView() {
|
|
261
|
+
if (!listboxEl || activeIndex < 0) return;
|
|
262
|
+
const active = listboxEl.querySelector(`[data-option-index="${activeIndex}"]`) as HTMLElement;
|
|
263
|
+
if (!active) return;
|
|
264
|
+
const listTop = listboxEl.scrollTop;
|
|
265
|
+
const listHeight = listboxEl.clientHeight;
|
|
266
|
+
const elTop = active.offsetTop;
|
|
267
|
+
const elHeight = active.offsetHeight;
|
|
268
|
+
if (elTop < listTop) {
|
|
269
|
+
listboxEl.scrollTop = elTop;
|
|
270
|
+
} else if (elTop + elHeight > listTop + listHeight) {
|
|
271
|
+
listboxEl.scrollTop = elTop + elHeight - listHeight;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
276
|
+
if (!isOpen) return;
|
|
277
|
+
|
|
278
|
+
switch (e.key) {
|
|
279
|
+
case Keys.ArrowDown:
|
|
280
|
+
e.preventDefault();
|
|
281
|
+
{
|
|
282
|
+
const pos = enabledIndices.indexOf(activeIndex);
|
|
283
|
+
if (pos < enabledIndices.length - 1) {
|
|
284
|
+
activeIndex = enabledIndices[pos + 1];
|
|
285
|
+
} else if (pos === -1 && enabledIndices.length > 0) {
|
|
286
|
+
activeIndex = enabledIndices[0];
|
|
287
|
+
}
|
|
288
|
+
tick().then(scrollActiveIntoView);
|
|
289
|
+
}
|
|
290
|
+
break;
|
|
291
|
+
case Keys.ArrowUp:
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
{
|
|
294
|
+
const pos = enabledIndices.indexOf(activeIndex);
|
|
295
|
+
if (pos > 0) {
|
|
296
|
+
activeIndex = enabledIndices[pos - 1];
|
|
297
|
+
}
|
|
298
|
+
tick().then(scrollActiveIntoView);
|
|
299
|
+
}
|
|
300
|
+
break;
|
|
301
|
+
case Keys.Enter:
|
|
302
|
+
e.preventDefault();
|
|
303
|
+
if (activeIndex >= 0 && displayOptions[activeIndex]) {
|
|
304
|
+
selectOption(displayOptions[activeIndex]);
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
case Keys.Escape:
|
|
308
|
+
e.preventDefault();
|
|
309
|
+
isOpen = false;
|
|
310
|
+
activeIndex = -1;
|
|
311
|
+
break;
|
|
312
|
+
case Keys.Home:
|
|
313
|
+
e.preventDefault();
|
|
314
|
+
if (enabledIndices.length > 0) {
|
|
315
|
+
activeIndex = enabledIndices[0];
|
|
316
|
+
tick().then(scrollActiveIntoView);
|
|
317
|
+
}
|
|
318
|
+
break;
|
|
319
|
+
case Keys.End:
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
if (enabledIndices.length > 0) {
|
|
322
|
+
activeIndex = enabledIndices[enabledIndices.length - 1];
|
|
323
|
+
tick().then(scrollActiveIntoView);
|
|
324
|
+
}
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function escapeHtml(str: string): string {
|
|
330
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function highlightMatch(text: string): string {
|
|
334
|
+
if (!value || value.length < minChars) return escapeHtml(text);
|
|
335
|
+
const idx = text.toLowerCase().indexOf(value.toLowerCase());
|
|
336
|
+
if (idx === -1) return escapeHtml(text);
|
|
337
|
+
return (
|
|
338
|
+
escapeHtml(text.slice(0, idx)) +
|
|
339
|
+
'<mark class="sx-search-highlight">' +
|
|
340
|
+
escapeHtml(text.slice(idx, idx + value.length)) +
|
|
341
|
+
'</mark>' +
|
|
342
|
+
escapeHtml(text.slice(idx + value.length))
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function getOptionId(index: number) {
|
|
347
|
+
return `${id}-option-${index}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function handleReposition() {
|
|
351
|
+
if (isOpen) positionDropdown();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Portal dropdown to body
|
|
355
|
+
$effect(() => {
|
|
356
|
+
if (listboxEl && isOpen) {
|
|
357
|
+
document.body.appendChild(listboxEl);
|
|
358
|
+
return () => {
|
|
359
|
+
if (listboxEl?.parentNode === document.body) {
|
|
360
|
+
document.body.removeChild(listboxEl);
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
onMount(() => {
|
|
367
|
+
window.addEventListener('scroll', handleReposition, true);
|
|
368
|
+
window.addEventListener('resize', handleReposition);
|
|
369
|
+
return () => {
|
|
370
|
+
window.removeEventListener('scroll', handleReposition, true);
|
|
371
|
+
window.removeEventListener('resize', handleReposition);
|
|
372
|
+
clearTimeout(debounceTimer);
|
|
373
|
+
clearTimeout(blurTimer);
|
|
374
|
+
if (listboxEl?.parentNode === document.body) {
|
|
375
|
+
document.body.removeChild(listboxEl);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
</script>
|
|
380
|
+
|
|
381
|
+
<div
|
|
382
|
+
class={cn('sx-search-wrapper', `sx-search-${size}`, disabled && 'sx-search-disabled', className)}
|
|
383
|
+
data-testid={testId}
|
|
384
|
+
>
|
|
385
|
+
<label id={labelId} for={id} class={cn('sx-search-label', hideLabel && 'sx-sr-only')}>
|
|
386
|
+
{label}
|
|
387
|
+
{#if required}
|
|
388
|
+
<span class="sx-search-required" aria-hidden="true">*</span>
|
|
389
|
+
{/if}
|
|
390
|
+
</label>
|
|
391
|
+
|
|
392
|
+
<div
|
|
393
|
+
class={cn(
|
|
394
|
+
'sx-search-field-wrapper',
|
|
395
|
+
isFocused && 'sx-search-focused',
|
|
396
|
+
showError && 'sx-search-error-state'
|
|
397
|
+
)}
|
|
398
|
+
>
|
|
399
|
+
<span class="sx-search-icon" aria-hidden="true">
|
|
400
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
401
|
+
<circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" />
|
|
402
|
+
</svg>
|
|
403
|
+
</span>
|
|
404
|
+
|
|
405
|
+
<input
|
|
406
|
+
bind:this={inputRef}
|
|
407
|
+
{id}
|
|
408
|
+
type="search"
|
|
409
|
+
role="combobox"
|
|
410
|
+
bind:value
|
|
411
|
+
{placeholder}
|
|
412
|
+
{disabled}
|
|
413
|
+
{required}
|
|
414
|
+
autocomplete="off"
|
|
415
|
+
aria-expanded={isOpen}
|
|
416
|
+
aria-haspopup="listbox"
|
|
417
|
+
aria-controls={isOpen ? listboxId : undefined}
|
|
418
|
+
aria-activedescendant={isOpen && activeIndex >= 0 ? getOptionId(activeIndex) : undefined}
|
|
419
|
+
aria-labelledby={labelId}
|
|
420
|
+
aria-describedby={ariaDescribedBy}
|
|
421
|
+
aria-invalid={showError || undefined}
|
|
422
|
+
class="sx-search-input"
|
|
423
|
+
oninput={handleInput}
|
|
424
|
+
onfocus={handleFocus}
|
|
425
|
+
onblur={handleBlur}
|
|
426
|
+
onkeydown={handleKeydown}
|
|
427
|
+
/>
|
|
428
|
+
|
|
429
|
+
{#if loading}
|
|
430
|
+
<span class="sx-search-loading" aria-hidden="true">
|
|
431
|
+
<svg class="sx-search-spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
432
|
+
<circle cx="12" cy="12" r="10" stroke-opacity="0.25" /><path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
|
|
433
|
+
</svg>
|
|
434
|
+
</span>
|
|
435
|
+
{:else if clearable && value && !disabled}
|
|
436
|
+
<button
|
|
437
|
+
type="button"
|
|
438
|
+
class="sx-search-clear"
|
|
439
|
+
onclick={handleClear}
|
|
440
|
+
aria-label="Clear search"
|
|
441
|
+
tabindex={-1}
|
|
442
|
+
>
|
|
443
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
|
444
|
+
</button>
|
|
445
|
+
{/if}
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
{#if alwaysShowFooter || hasFooterContent}
|
|
449
|
+
<div class="sx-search-footer">
|
|
450
|
+
{#if showError}
|
|
451
|
+
<p id={errorId} class="sx-search-error" role="alert" aria-live="assertive">{error}</p>
|
|
452
|
+
{:else if hint}
|
|
453
|
+
<p id={hintId} class="sx-search-hint">{hint}</p>
|
|
454
|
+
{/if}
|
|
455
|
+
</div>
|
|
456
|
+
{/if}
|
|
457
|
+
</div>
|
|
458
|
+
|
|
459
|
+
<!-- Dropdown panel -->
|
|
460
|
+
{#if isOpen}
|
|
461
|
+
<div
|
|
462
|
+
bind:this={listboxEl}
|
|
463
|
+
id={listboxId}
|
|
464
|
+
class="sx-search-panel"
|
|
465
|
+
style="position:fixed;left:{panelLeft}px;{placeAbove ? `bottom:${window.innerHeight - panelTop}px` : `top:${panelTop}px`};width:{panelWidth}px;max-height:{panelMaxHeight}px;"
|
|
466
|
+
role="listbox"
|
|
467
|
+
aria-labelledby={labelId}
|
|
468
|
+
tabindex="-1"
|
|
469
|
+
onmousedown={(e) => e.preventDefault()}
|
|
470
|
+
>
|
|
471
|
+
{#if loading && displayOptions.length === 0}
|
|
472
|
+
<div class="sx-search-loading-msg">Searching...</div>
|
|
473
|
+
{:else if displayOptions.length === 0}
|
|
474
|
+
<div class="sx-search-empty">No results found</div>
|
|
475
|
+
{:else if filteredGroups.length > 0}
|
|
476
|
+
{@const groupOffsets = filteredGroups.reduce<number[]>((acc, g, i) => {
|
|
477
|
+
acc.push(i === 0 ? 0 : acc[i - 1] + filteredGroups[i - 1].options.length);
|
|
478
|
+
return acc;
|
|
479
|
+
}, [])}
|
|
480
|
+
{#each filteredGroups as group, gi}
|
|
481
|
+
<div role="group" aria-label={group.label}>
|
|
482
|
+
<div class="sx-search-group-label">{group.label}</div>
|
|
483
|
+
{#each group.options as opt, oi}
|
|
484
|
+
{@const globalIdx = groupOffsets[gi] + oi}
|
|
485
|
+
{@const isActive = globalIdx === activeIndex}
|
|
486
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
487
|
+
<div
|
|
488
|
+
id={getOptionId(globalIdx)}
|
|
489
|
+
class={cn(
|
|
490
|
+
'sx-search-option',
|
|
491
|
+
isActive && 'sx-search-option-active',
|
|
492
|
+
opt.disabled && 'sx-search-option-disabled'
|
|
493
|
+
)}
|
|
494
|
+
role="option"
|
|
495
|
+
tabindex="-1"
|
|
496
|
+
aria-selected={isActive}
|
|
497
|
+
aria-disabled={opt.disabled || undefined}
|
|
498
|
+
data-option-index={globalIdx}
|
|
499
|
+
onmouseenter={() => { if (!opt.disabled) activeIndex = globalIdx; }}
|
|
500
|
+
onmousedown={(e) => { e.preventDefault(); selectOption(opt); }}
|
|
501
|
+
>
|
|
502
|
+
<span class="sx-search-option-label">{@html highlightMatch(opt.label)}</span>
|
|
503
|
+
{#if opt.description}
|
|
504
|
+
<span class="sx-search-option-desc">{opt.description}</span>
|
|
505
|
+
{/if}
|
|
506
|
+
</div>
|
|
507
|
+
{/each}
|
|
508
|
+
</div>
|
|
509
|
+
{/each}
|
|
510
|
+
{:else}
|
|
511
|
+
{#each displayOptions as opt, i}
|
|
512
|
+
{@const isActive = i === activeIndex}
|
|
513
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
514
|
+
<div
|
|
515
|
+
id={getOptionId(i)}
|
|
516
|
+
class={cn(
|
|
517
|
+
'sx-search-option',
|
|
518
|
+
isActive && 'sx-search-option-active',
|
|
519
|
+
opt.disabled && 'sx-search-option-disabled'
|
|
520
|
+
)}
|
|
521
|
+
role="option"
|
|
522
|
+
tabindex="-1"
|
|
523
|
+
aria-selected={isActive}
|
|
524
|
+
aria-disabled={opt.disabled || undefined}
|
|
525
|
+
data-option-index={i}
|
|
526
|
+
onmouseenter={() => { if (!opt.disabled) activeIndex = i; }}
|
|
527
|
+
onmousedown={(e) => { e.preventDefault(); selectOption(opt); }}
|
|
528
|
+
>
|
|
529
|
+
<span class="sx-search-option-label">{@html highlightMatch(opt.label)}</span>
|
|
530
|
+
{#if opt.description}
|
|
531
|
+
<span class="sx-search-option-desc">{opt.description}</span>
|
|
532
|
+
{/if}
|
|
533
|
+
</div>
|
|
534
|
+
{/each}
|
|
535
|
+
{/if}
|
|
536
|
+
</div>
|
|
537
|
+
{/if}
|
|
538
|
+
|
|
539
|
+
<style>
|
|
540
|
+
.sx-search-wrapper {
|
|
541
|
+
display: flex;
|
|
542
|
+
flex-direction: column;
|
|
543
|
+
gap: var(--sx-space-1);
|
|
544
|
+
font-family: var(--sx-font-body);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
.sx-search-disabled {
|
|
548
|
+
opacity: 0.5;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
.sx-search-label {
|
|
552
|
+
font-size: var(--sx-text-sm);
|
|
553
|
+
font-weight: 500;
|
|
554
|
+
color: var(--sx-color-text-secondary);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.sx-search-required {
|
|
558
|
+
color: var(--sx-color-red);
|
|
559
|
+
margin-left: 2px;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
.sx-sr-only {
|
|
563
|
+
position: absolute;
|
|
564
|
+
width: 1px;
|
|
565
|
+
height: 1px;
|
|
566
|
+
padding: 0;
|
|
567
|
+
margin: -1px;
|
|
568
|
+
overflow: hidden;
|
|
569
|
+
clip: rect(0, 0, 0, 0);
|
|
570
|
+
white-space: nowrap;
|
|
571
|
+
border: 0;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
.sx-search-field-wrapper {
|
|
575
|
+
position: relative;
|
|
576
|
+
display: flex;
|
|
577
|
+
align-items: center;
|
|
578
|
+
background: var(--sx-color-surface);
|
|
579
|
+
border: 1px solid var(--sx-color-border-strong);
|
|
580
|
+
border-radius: var(--sx-radius-md);
|
|
581
|
+
transition: border-color var(--sx-transition-fast), box-shadow var(--sx-transition-fast);
|
|
582
|
+
box-shadow:
|
|
583
|
+
inset 0 1px 3px rgba(0, 0, 0, 0.3),
|
|
584
|
+
inset 0 0 0 1px rgba(0, 0, 0, 0.06);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.sx-search-field-wrapper:hover:not(.sx-search-focused):not(.sx-search-error-state) {
|
|
588
|
+
border-color: var(--sx-color-border-hover);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
.sx-search-focused {
|
|
592
|
+
border-color: var(--sx-color-primary);
|
|
593
|
+
box-shadow:
|
|
594
|
+
inset 0 1px 2px rgba(0, 0, 0, 0.2),
|
|
595
|
+
0 0 0 3px var(--sx-color-primary-ring),
|
|
596
|
+
0 0 12px -4px rgba(255, 107, 53, 0.15);
|
|
597
|
+
animation: sx-focus-breathe 2s ease-in-out infinite;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.sx-search-error-state {
|
|
601
|
+
border-color: var(--sx-color-red);
|
|
602
|
+
box-shadow:
|
|
603
|
+
inset 0 1px 2px rgba(0, 0, 0, 0.2),
|
|
604
|
+
0 0 0 3px var(--sx-color-red-ring);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
.sx-search-icon {
|
|
608
|
+
display: flex;
|
|
609
|
+
align-items: center;
|
|
610
|
+
padding-left: var(--sx-space-3);
|
|
611
|
+
color: var(--sx-color-text-secondary);
|
|
612
|
+
flex-shrink: 0;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.sx-search-input {
|
|
616
|
+
flex: 1;
|
|
617
|
+
min-width: 0;
|
|
618
|
+
border: none;
|
|
619
|
+
background: transparent;
|
|
620
|
+
color: var(--sx-color-text);
|
|
621
|
+
font-family: var(--sx-font-body);
|
|
622
|
+
font-size: var(--sx-text-sm);
|
|
623
|
+
outline: none;
|
|
624
|
+
padding: var(--sx-space-2) var(--sx-space-3);
|
|
625
|
+
/* Remove native search cancel button */
|
|
626
|
+
appearance: none;
|
|
627
|
+
-webkit-appearance: none;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
.sx-search-input::-webkit-search-cancel-button,
|
|
631
|
+
.sx-search-input::-webkit-search-decoration {
|
|
632
|
+
-webkit-appearance: none;
|
|
633
|
+
display: none;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
.sx-search-input:focus-visible {
|
|
637
|
+
box-shadow: none;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.sx-search-input::placeholder {
|
|
641
|
+
color: var(--sx-color-text-disabled);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/* Sizes */
|
|
645
|
+
.sx-search-sm .sx-search-field-wrapper { min-height: 32px; }
|
|
646
|
+
.sx-search-sm .sx-search-input { font-size: var(--sx-text-xs); padding: var(--sx-space-1) var(--sx-space-2); }
|
|
647
|
+
.sx-search-md .sx-search-field-wrapper { min-height: 40px; }
|
|
648
|
+
.sx-search-lg .sx-search-field-wrapper { min-height: 48px; }
|
|
649
|
+
.sx-search-lg .sx-search-input { font-size: var(--sx-text-base); padding: var(--sx-space-3) var(--sx-space-4); }
|
|
650
|
+
|
|
651
|
+
.sx-search-loading {
|
|
652
|
+
display: flex;
|
|
653
|
+
align-items: center;
|
|
654
|
+
padding-right: var(--sx-space-3);
|
|
655
|
+
color: var(--sx-color-primary);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.sx-search-spinner {
|
|
659
|
+
animation: sx-spin-search 0.8s linear infinite;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
@keyframes sx-spin-search {
|
|
663
|
+
to { transform: rotate(360deg); }
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.sx-search-clear {
|
|
667
|
+
display: flex;
|
|
668
|
+
align-items: center;
|
|
669
|
+
justify-content: center;
|
|
670
|
+
width: 28px;
|
|
671
|
+
height: 28px;
|
|
672
|
+
padding: 0;
|
|
673
|
+
margin-right: var(--sx-space-1);
|
|
674
|
+
border: none;
|
|
675
|
+
border-radius: var(--sx-radius-sm);
|
|
676
|
+
background: transparent;
|
|
677
|
+
color: var(--sx-color-text-secondary);
|
|
678
|
+
cursor: pointer;
|
|
679
|
+
transition: background var(--sx-transition-fast), color var(--sx-transition-fast);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
.sx-search-clear:hover {
|
|
683
|
+
background: var(--sx-color-surface-2);
|
|
684
|
+
color: var(--sx-color-text);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/* Footer */
|
|
688
|
+
.sx-search-footer {
|
|
689
|
+
min-height: 1.25rem;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
.sx-search-error {
|
|
693
|
+
font-size: var(--sx-text-xs);
|
|
694
|
+
font-weight: 500;
|
|
695
|
+
color: var(--sx-color-red);
|
|
696
|
+
margin: 0;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
.sx-search-hint {
|
|
700
|
+
font-size: var(--sx-text-xs);
|
|
701
|
+
color: var(--sx-color-text-secondary);
|
|
702
|
+
margin: 0;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/* Dropdown panel */
|
|
706
|
+
.sx-search-panel {
|
|
707
|
+
z-index: var(--sx-z-dropdown);
|
|
708
|
+
overflow-y: auto;
|
|
709
|
+
overflow-x: hidden;
|
|
710
|
+
background: var(--sx-color-surface-2);
|
|
711
|
+
border: 1px solid var(--sx-color-border-strong);
|
|
712
|
+
border-radius: var(--sx-radius-md);
|
|
713
|
+
box-shadow: var(--sx-shadow-lg);
|
|
714
|
+
backdrop-filter: var(--sx-glass-blur);
|
|
715
|
+
-webkit-backdrop-filter: var(--sx-glass-blur);
|
|
716
|
+
outline: none;
|
|
717
|
+
padding: var(--sx-space-1) 0;
|
|
718
|
+
font-family: var(--sx-font-body);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
.sx-search-option {
|
|
722
|
+
display: flex;
|
|
723
|
+
flex-direction: column;
|
|
724
|
+
gap: 2px;
|
|
725
|
+
padding: var(--sx-space-2) var(--sx-space-4);
|
|
726
|
+
font-size: var(--sx-text-sm);
|
|
727
|
+
color: var(--sx-color-text);
|
|
728
|
+
cursor: pointer;
|
|
729
|
+
user-select: none;
|
|
730
|
+
transition: background var(--sx-transition-fast);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
.sx-search-option-active {
|
|
734
|
+
background: var(--sx-color-primary-hover);
|
|
735
|
+
color: var(--sx-color-primary);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
.sx-search-option-disabled {
|
|
739
|
+
opacity: 0.4;
|
|
740
|
+
cursor: not-allowed;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
.sx-search-option-label {
|
|
744
|
+
font-weight: 500;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
.sx-search-option-desc {
|
|
748
|
+
font-size: var(--sx-text-xs);
|
|
749
|
+
color: var(--sx-color-text-secondary);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
.sx-search-option-active .sx-search-option-desc {
|
|
753
|
+
color: var(--sx-color-primary);
|
|
754
|
+
opacity: 0.7;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
:global(.sx-search-highlight) {
|
|
758
|
+
background: rgba(255, 107, 53, 0.15);
|
|
759
|
+
color: var(--sx-color-primary);
|
|
760
|
+
border-radius: 2px;
|
|
761
|
+
padding: 0 1px;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
.sx-search-group-label {
|
|
765
|
+
padding: var(--sx-space-2) var(--sx-space-4) var(--sx-space-1);
|
|
766
|
+
font-size: var(--sx-text-xs);
|
|
767
|
+
font-weight: 600;
|
|
768
|
+
letter-spacing: 0.05em;
|
|
769
|
+
text-transform: uppercase;
|
|
770
|
+
color: var(--sx-color-text-disabled);
|
|
771
|
+
user-select: none;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.sx-search-empty,
|
|
775
|
+
.sx-search-loading-msg {
|
|
776
|
+
padding: var(--sx-space-4);
|
|
777
|
+
text-align: center;
|
|
778
|
+
font-size: var(--sx-text-sm);
|
|
779
|
+
color: var(--sx-color-text-disabled);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
@media (prefers-reduced-motion: reduce) {
|
|
783
|
+
.sx-search-field-wrapper { transition: none; }
|
|
784
|
+
.sx-search-focused { animation: none; }
|
|
785
|
+
.sx-search-spinner { animation: none; }
|
|
786
|
+
.sx-search-option { transition: none; }
|
|
787
|
+
}
|
|
788
|
+
</style>
|