@salmexio/ui 1.2.0 → 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.
Files changed (78) hide show
  1. package/dist/feedback/Alert/Alert.svelte +4 -1
  2. package/dist/feedback/Alert/Alert.svelte.d.ts +1 -0
  3. package/dist/feedback/Alert/Alert.svelte.d.ts.map +1 -1
  4. package/dist/feedback/Spinner/Spinner.svelte +4 -1
  5. package/dist/feedback/Spinner/Spinner.svelte.d.ts +1 -0
  6. package/dist/feedback/Spinner/Spinner.svelte.d.ts.map +1 -1
  7. package/dist/forms/DatePicker/DatePicker.svelte +725 -0
  8. package/dist/forms/DatePicker/DatePicker.svelte.d.ts +48 -0
  9. package/dist/forms/DatePicker/DatePicker.svelte.d.ts.map +1 -0
  10. package/dist/forms/DatePicker/index.d.ts +2 -0
  11. package/dist/forms/DatePicker/index.d.ts.map +1 -0
  12. package/dist/forms/DatePicker/index.js +1 -0
  13. package/dist/forms/FormField/FormField.svelte +173 -0
  14. package/dist/forms/FormField/FormField.svelte.d.ts +46 -0
  15. package/dist/forms/FormField/FormField.svelte.d.ts.map +1 -0
  16. package/dist/forms/FormField/index.d.ts +2 -0
  17. package/dist/forms/FormField/index.d.ts.map +1 -0
  18. package/dist/forms/FormField/index.js +1 -0
  19. package/dist/forms/MultiSelect/MultiSelect.svelte +820 -0
  20. package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts +69 -0
  21. package/dist/forms/MultiSelect/MultiSelect.svelte.d.ts.map +1 -0
  22. package/dist/forms/MultiSelect/index.d.ts +3 -0
  23. package/dist/forms/MultiSelect/index.d.ts.map +1 -0
  24. package/dist/forms/MultiSelect/index.js +1 -0
  25. package/dist/forms/PhoneInput/PhoneInput.svelte +591 -0
  26. package/dist/forms/PhoneInput/PhoneInput.svelte.d.ts +57 -0
  27. package/dist/forms/PhoneInput/PhoneInput.svelte.d.ts.map +1 -0
  28. package/dist/forms/PhoneInput/index.d.ts +4 -0
  29. package/dist/forms/PhoneInput/index.d.ts.map +1 -0
  30. package/dist/forms/PhoneInput/index.js +2 -0
  31. package/dist/forms/RadioGroup/RadioGroup.svelte +417 -0
  32. package/dist/forms/RadioGroup/RadioGroup.svelte.d.ts +62 -0
  33. package/dist/forms/RadioGroup/RadioGroup.svelte.d.ts.map +1 -0
  34. package/dist/forms/RadioGroup/index.d.ts +3 -0
  35. package/dist/forms/RadioGroup/index.d.ts.map +1 -0
  36. package/dist/forms/RadioGroup/index.js +1 -0
  37. package/dist/forms/SearchInput/SearchInput.svelte +788 -0
  38. package/dist/forms/SearchInput/SearchInput.svelte.d.ts +79 -0
  39. package/dist/forms/SearchInput/SearchInput.svelte.d.ts.map +1 -0
  40. package/dist/forms/SearchInput/index.d.ts +3 -0
  41. package/dist/forms/SearchInput/index.d.ts.map +1 -0
  42. package/dist/forms/SearchInput/index.js +1 -0
  43. package/dist/forms/Select/Select.svelte +14 -8
  44. package/dist/forms/Select/Select.svelte.d.ts +2 -0
  45. package/dist/forms/Select/Select.svelte.d.ts.map +1 -1
  46. package/dist/forms/TextInput/TextInput.svelte +38 -16
  47. package/dist/forms/TextInput/TextInput.svelte.d.ts +6 -0
  48. package/dist/forms/TextInput/TextInput.svelte.d.ts.map +1 -1
  49. package/dist/forms/Textarea/Textarea.svelte +7 -1
  50. package/dist/forms/Textarea/Textarea.svelte.d.ts +2 -0
  51. package/dist/forms/Textarea/Textarea.svelte.d.ts.map +1 -1
  52. package/dist/forms/TimePicker/TimePicker.svelte +417 -0
  53. package/dist/forms/TimePicker/TimePicker.svelte.d.ts +53 -0
  54. package/dist/forms/TimePicker/TimePicker.svelte.d.ts.map +1 -0
  55. package/dist/forms/TimePicker/index.d.ts +2 -0
  56. package/dist/forms/TimePicker/index.d.ts.map +1 -0
  57. package/dist/forms/TimePicker/index.js +1 -0
  58. package/dist/forms/index.d.ts +12 -0
  59. package/dist/forms/index.d.ts.map +1 -1
  60. package/dist/forms/index.js +8 -0
  61. package/dist/layout/Container/Container.svelte +3 -0
  62. package/dist/layout/Container/Container.svelte.d.ts +1 -0
  63. package/dist/layout/Container/Container.svelte.d.ts.map +1 -1
  64. package/dist/primitives/Badge/Badge.svelte +5 -1
  65. package/dist/primitives/Badge/Badge.svelte.d.ts +1 -0
  66. package/dist/primitives/Badge/Badge.svelte.d.ts.map +1 -1
  67. package/dist/primitives/Tooltip/Tooltip.svelte +30 -0
  68. package/dist/primitives/Tooltip/Tooltip.svelte.d.ts.map +1 -1
  69. package/dist/utils/accessibility.d.ts +16 -0
  70. package/dist/utils/accessibility.d.ts.map +1 -0
  71. package/dist/utils/accessibility.js +80 -0
  72. package/dist/utils/index.d.ts +2 -1
  73. package/dist/utils/index.d.ts.map +1 -1
  74. package/dist/utils/index.js +2 -1
  75. package/dist/utils/keyboard.d.ts +6 -0
  76. package/dist/utils/keyboard.d.ts.map +1 -1
  77. package/dist/utils/keyboard.js +15 -9
  78. package/package.json +21 -1
@@ -0,0 +1,69 @@
1
+ export interface MultiSelectOption {
2
+ value: string;
3
+ label: string;
4
+ disabled?: boolean;
5
+ }
6
+ export interface MultiSelectGroup {
7
+ label: string;
8
+ options: MultiSelectOption[];
9
+ }
10
+ type MultiSelectSize = 'sm' | 'md' | 'lg';
11
+ interface Props {
12
+ /** Visible label. */
13
+ label: string;
14
+ /** Flat list of options. */
15
+ options?: MultiSelectOption[];
16
+ /** Grouped options. */
17
+ groups?: MultiSelectGroup[];
18
+ /** Selected values (bindable). */
19
+ values?: string[];
20
+ /** Placeholder when nothing selected. */
21
+ placeholder?: string;
22
+ /** Enable search filtering. */
23
+ searchable?: boolean;
24
+ /** Max number of selections allowed. */
25
+ maxSelections?: number;
26
+ /** Error message. */
27
+ error?: string;
28
+ /** Hint text. */
29
+ hint?: string;
30
+ /** Size variant. */
31
+ size?: MultiSelectSize;
32
+ /** Disabled state. */
33
+ disabled?: boolean;
34
+ /** Required field. */
35
+ required?: boolean;
36
+ /** Hide the visible label. */
37
+ hideLabel?: boolean;
38
+ /** Reserve footer space even when empty. */
39
+ alwaysShowFooter?: boolean;
40
+ /** Additional CSS class. */
41
+ class?: string;
42
+ /** Called when values change. */
43
+ onchange?: (values: string[]) => void;
44
+ /** Test ID. */
45
+ testId?: string;
46
+ }
47
+ /**
48
+ * MultiSelect
49
+ *
50
+ * INFRARED — Multi-selection with removable chips, searchable dropdown,
51
+ * max selection limit, and keyboard navigation.
52
+ * Uses WAI-ARIA combobox + listbox pattern.
53
+ *
54
+ * @example
55
+ * <MultiSelect
56
+ * label="Capabilities"
57
+ * options={[
58
+ * { value: 'code', label: 'Code generation' },
59
+ * { value: 'search', label: 'Web search' },
60
+ * { value: 'vision', label: 'Image analysis' },
61
+ * ]}
62
+ * bind:values={selectedCaps}
63
+ * maxSelections={5}
64
+ * />
65
+ */
66
+ declare const MultiSelect: import("svelte").Component<Props, {}, "values">;
67
+ type MultiSelect = ReturnType<typeof MultiSelect>;
68
+ export default MultiSelect;
69
+ //# sourceMappingURL=MultiSelect.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"MultiSelect.svelte.d.ts","sourceRoot":"","sources":["../../../src/forms/MultiSelect/MultiSelect.svelte.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,iBAAiB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,iBAAiB,EAAE,CAAC;CAC7B;AAQD,KAAK,eAAe,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE1C,UAAU,KAAK;IACd,qBAAqB;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,4BAA4B;IAC5B,OAAO,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAC9B,uBAAuB;IACvB,MAAM,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC5B,kCAAkC;IAClC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,yCAAyC;IACzC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,+BAA+B;IAC/B,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,wCAAwC;IACxC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,qBAAqB;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iBAAiB;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB;IACpB,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,sBAAsB;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sBAAsB;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8BAA8B;IAC9B,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,4CAA4C;IAC5C,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,4BAA4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iCAAiC;IACjC,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAC;IACtC,eAAe;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAmaD;;;;;;;;;;;;;;;;;;GAkBG;AACH,QAAA,MAAM,WAAW,iDAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { default as MultiSelect } from './MultiSelect.svelte';
2
+ export type { MultiSelectOption, MultiSelectGroup } from './MultiSelect.svelte';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/forms/MultiSelect/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,YAAY,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC"}
@@ -0,0 +1 @@
1
+ export { default as MultiSelect } from './MultiSelect.svelte';
@@ -0,0 +1,591 @@
1
+ <!--
2
+ @component PhoneInput
3
+
4
+ INFRARED — International phone number input with country code selector,
5
+ auto-formatting, and E.164 output. Zero external dependencies.
6
+ Country list is curated for common use; extend via the countries prop.
7
+
8
+ @example
9
+ <PhoneInput label="Phone number" bind:value={phone} />
10
+ <PhoneInput label="Mobile" defaultCountry="GB" />
11
+ -->
12
+ <script lang="ts" module>
13
+ export interface Country {
14
+ code: string;
15
+ name: string;
16
+ dial: string;
17
+ flag: string;
18
+ }
19
+
20
+ export const DEFAULT_COUNTRIES: Country[] = [
21
+ { code: 'US', name: 'United States', dial: '+1', flag: '\u{1F1FA}\u{1F1F8}' },
22
+ { code: 'GB', name: 'United Kingdom', dial: '+44', flag: '\u{1F1EC}\u{1F1E7}' },
23
+ { code: 'CA', name: 'Canada', dial: '+1', flag: '\u{1F1E8}\u{1F1E6}' },
24
+ { code: 'AU', name: 'Australia', dial: '+61', flag: '\u{1F1E6}\u{1F1FA}' },
25
+ { code: 'DE', name: 'Germany', dial: '+49', flag: '\u{1F1E9}\u{1F1EA}' },
26
+ { code: 'FR', name: 'France', dial: '+33', flag: '\u{1F1EB}\u{1F1F7}' },
27
+ { code: 'JP', name: 'Japan', dial: '+81', flag: '\u{1F1EF}\u{1F1F5}' },
28
+ { code: 'IN', name: 'India', dial: '+91', flag: '\u{1F1EE}\u{1F1F3}' },
29
+ { code: 'BR', name: 'Brazil', dial: '+55', flag: '\u{1F1E7}\u{1F1F7}' },
30
+ { code: 'MX', name: 'Mexico', dial: '+52', flag: '\u{1F1F2}\u{1F1FD}' },
31
+ { code: 'ES', name: 'Spain', dial: '+34', flag: '\u{1F1EA}\u{1F1F8}' },
32
+ { code: 'IT', name: 'Italy', dial: '+39', flag: '\u{1F1EE}\u{1F1F9}' },
33
+ { code: 'NL', name: 'Netherlands', dial: '+31', flag: '\u{1F1F3}\u{1F1F1}' },
34
+ { code: 'SE', name: 'Sweden', dial: '+46', flag: '\u{1F1F8}\u{1F1EA}' },
35
+ { code: 'CH', name: 'Switzerland', dial: '+41', flag: '\u{1F1E8}\u{1F1ED}' },
36
+ { code: 'KR', name: 'South Korea', dial: '+82', flag: '\u{1F1F0}\u{1F1F7}' },
37
+ { code: 'SG', name: 'Singapore', dial: '+65', flag: '\u{1F1F8}\u{1F1EC}' },
38
+ { code: 'AE', name: 'UAE', dial: '+971', flag: '\u{1F1E6}\u{1F1EA}' },
39
+ { code: 'IL', name: 'Israel', dial: '+972', flag: '\u{1F1EE}\u{1F1F1}' },
40
+ { code: 'SA', name: 'Saudi Arabia', dial: '+966', flag: '\u{1F1F8}\u{1F1E6}' },
41
+ { code: 'MA', name: 'Morocco', dial: '+212', flag: '\u{1F1F2}\u{1F1E6}' },
42
+ { code: 'NG', name: 'Nigeria', dial: '+234', flag: '\u{1F1F3}\u{1F1EC}' },
43
+ { code: 'ZA', name: 'South Africa', dial: '+27', flag: '\u{1F1FF}\u{1F1E6}' },
44
+ { code: 'CN', name: 'China', dial: '+86', flag: '\u{1F1E8}\u{1F1F3}' },
45
+ { code: 'RU', name: 'Russia', dial: '+7', flag: '\u{1F1F7}\u{1F1FA}' },
46
+ ];
47
+ </script>
48
+
49
+ <script lang="ts">
50
+ import { cn } from '../../utils/cn.js';
51
+ import { Keys, generateId } from '../../utils/keyboard.js';
52
+ import { onMount } from 'svelte';
53
+
54
+ type PhoneSize = 'sm' | 'md' | 'lg';
55
+
56
+ interface Props {
57
+ /** Visible label. */
58
+ label: string;
59
+ /** Phone number value (E.164 format: +1234567890). */
60
+ value?: string;
61
+ /** Default country code. */
62
+ defaultCountry?: string;
63
+ /** Available countries list. */
64
+ countries?: Country[];
65
+ /** Placeholder for the number field. */
66
+ placeholder?: string;
67
+ /** Size variant. */
68
+ size?: PhoneSize;
69
+ /** Required field. */
70
+ required?: boolean;
71
+ /** Disabled state. */
72
+ disabled?: boolean;
73
+ /** Error message. */
74
+ error?: string;
75
+ /** Hint text. */
76
+ hint?: string;
77
+ /** Hide the visible label. */
78
+ hideLabel?: boolean;
79
+ /** Reserve footer space even when empty. */
80
+ alwaysShowFooter?: boolean;
81
+ /** Additional CSS class. */
82
+ class?: string;
83
+ /** Called when value changes. */
84
+ onchange?: (value: string) => void;
85
+ /** Custom validation function. */
86
+ validate?: (value: string) => string | undefined;
87
+ /** Test ID. */
88
+ testId?: string;
89
+ }
90
+
91
+ let {
92
+ label,
93
+ value = $bindable(''),
94
+ defaultCountry = 'US',
95
+ countries = DEFAULT_COUNTRIES,
96
+ placeholder = 'Phone number',
97
+ size = 'md',
98
+ required = false,
99
+ disabled = false,
100
+ error = '',
101
+ hint = '',
102
+ hideLabel = false,
103
+ alwaysShowFooter = true,
104
+ class: className = '',
105
+ onchange,
106
+ validate,
107
+ testId
108
+ }: Props = $props();
109
+
110
+ const id = generateId('phone');
111
+ const errorId = `${id}-error`;
112
+ const hintId = `${id}-hint`;
113
+ const dropdownId = `${id}-countries`;
114
+
115
+ const fallbackCountry: Country = { code: '', name: 'Unknown', dial: '+0', flag: '' };
116
+
117
+ // svelte-ignore state_referenced_locally — intentional: capture initial country from prop
118
+ let selectedCountry = $state<Country>(
119
+ countries.find((c) => c.code === defaultCountry) || countries[0] || fallbackCountry
120
+ );
121
+ let nationalNumber = $state('');
122
+ let isOpen = $state(false);
123
+ let isFocused = $state(false);
124
+ let hasInteracted = $state(false);
125
+ let activeIndex = $state(-1);
126
+ let countrySearch = $state('');
127
+ let searchTimer: ReturnType<typeof setTimeout> | undefined;
128
+
129
+ let triggerEl = $state<HTMLElement | null>(null);
130
+ let dropdownEl = $state<HTMLElement | null>(null);
131
+ let inputRef = $state<HTMLInputElement | null>(null);
132
+
133
+ let panelTop = $state(0);
134
+ let panelLeft = $state(0);
135
+ let panelWidth = $state(0);
136
+
137
+ // Parse initial value — use longest dial code match to handle +1 US/CA ambiguity
138
+ $effect(() => {
139
+ if (value && !nationalNumber) {
140
+ let match: Country | undefined;
141
+ for (const c of countries) {
142
+ if (value.startsWith(c.dial) && (!match || c.dial.length > match.dial.length)) {
143
+ match = c;
144
+ }
145
+ }
146
+ if (match) {
147
+ selectedCountry = match;
148
+ nationalNumber = value.slice(match.dial.length).replace(/\D/g, '');
149
+ }
150
+ }
151
+ });
152
+
153
+ const validationError = $derived.by(() => {
154
+ if (!validate) return undefined;
155
+ try { return validate(value); } catch { return undefined; }
156
+ });
157
+ const effectiveError = $derived(error || validationError || '');
158
+ const showError = $derived(hasInteracted && !!effectiveError);
159
+
160
+ const ariaDescribedBy = $derived(
161
+ [showError && errorId, hint && hintId].filter(Boolean).join(' ') || undefined
162
+ );
163
+ const hasFooterContent = $derived(showError || !!hint);
164
+
165
+ function formatNumber(num: string): string {
166
+ const digits = num.replace(/\D/g, '');
167
+ // Simple grouping: XXX XXX XXXX for most formats
168
+ if (digits.length <= 3) return digits;
169
+ if (digits.length <= 6) return `${digits.slice(0, 3)} ${digits.slice(3)}`;
170
+ return `${digits.slice(0, 3)} ${digits.slice(3, 6)} ${digits.slice(6, 10)}`;
171
+ }
172
+
173
+ function updateValue() {
174
+ const digits = nationalNumber.replace(/\D/g, '');
175
+ if (digits) {
176
+ // E.164: max 15 digits total (including country code digits)
177
+ const dialDigits = selectedCountry.dial.replace(/\D/g, '');
178
+ const maxNational = 15 - dialDigits.length;
179
+ const clamped = digits.slice(0, Math.max(1, maxNational));
180
+ value = `${selectedCountry.dial}${clamped}`;
181
+ } else {
182
+ value = '';
183
+ }
184
+ hasInteracted = true;
185
+ onchange?.(value);
186
+ }
187
+
188
+ function handleInput(e: Event) {
189
+ const target = e.target as HTMLInputElement;
190
+ // Strip non-digits
191
+ nationalNumber = target.value.replace(/[^\d\s]/g, '').replace(/\s+/g, ' ').trim();
192
+ updateValue();
193
+ }
194
+
195
+ function handleFocus() {
196
+ isFocused = true;
197
+ }
198
+
199
+ function handleBlur() {
200
+ isFocused = false;
201
+ hasInteracted = true;
202
+ // Format on blur
203
+ nationalNumber = formatNumber(nationalNumber);
204
+ }
205
+
206
+ function positionDropdown() {
207
+ if (!triggerEl) return;
208
+ const rect = triggerEl.getBoundingClientRect();
209
+ panelLeft = rect.left;
210
+ panelTop = rect.bottom + 2;
211
+ panelWidth = Math.max(rect.width, 240);
212
+ }
213
+
214
+ function openCountryPicker() {
215
+ if (disabled) return;
216
+ isOpen = true;
217
+ activeIndex = countries.indexOf(selectedCountry);
218
+ countrySearch = '';
219
+ positionDropdown();
220
+ }
221
+
222
+ function closeCountryPicker() {
223
+ isOpen = false;
224
+ activeIndex = -1;
225
+ countrySearch = '';
226
+ }
227
+
228
+ function selectCountry(country: Country) {
229
+ selectedCountry = country;
230
+ closeCountryPicker();
231
+ updateValue();
232
+ inputRef?.focus();
233
+ }
234
+
235
+ function handleCountryKeydown(e: KeyboardEvent) {
236
+ if (!isOpen) {
237
+ if (e.key === Keys.Enter || e.key === Keys.Space || e.key === Keys.ArrowDown) {
238
+ e.preventDefault();
239
+ openCountryPicker();
240
+ }
241
+ return;
242
+ }
243
+
244
+ switch (e.key) {
245
+ case Keys.ArrowDown:
246
+ e.preventDefault();
247
+ activeIndex = Math.min(activeIndex + 1, countries.length - 1);
248
+ break;
249
+ case Keys.ArrowUp:
250
+ e.preventDefault();
251
+ activeIndex = Math.max(activeIndex - 1, 0);
252
+ break;
253
+ case Keys.Enter:
254
+ case Keys.Space:
255
+ e.preventDefault();
256
+ if (activeIndex >= 0) selectCountry(countries[activeIndex]);
257
+ break;
258
+ case Keys.Escape:
259
+ e.preventDefault();
260
+ closeCountryPicker();
261
+ break;
262
+ default:
263
+ // Type-ahead
264
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
265
+ countrySearch += e.key.toLowerCase();
266
+ clearTimeout(searchTimer);
267
+ searchTimer = setTimeout(() => (countrySearch = ''), 500);
268
+ const match = countries.findIndex((c) =>
269
+ c.name.toLowerCase().startsWith(countrySearch)
270
+ );
271
+ if (match >= 0) activeIndex = match;
272
+ }
273
+ }
274
+ }
275
+
276
+ function handleClickOutside(e: MouseEvent) {
277
+ const target = e.target as Node;
278
+ if (triggerEl?.contains(target)) return;
279
+ if (dropdownEl?.contains(target)) return;
280
+ closeCountryPicker();
281
+ }
282
+
283
+ // Portal dropdown
284
+ $effect(() => {
285
+ if (dropdownEl && isOpen) {
286
+ document.body.appendChild(dropdownEl);
287
+ return () => {
288
+ if (dropdownEl?.parentNode === document.body) {
289
+ document.body.removeChild(dropdownEl);
290
+ }
291
+ };
292
+ }
293
+ });
294
+
295
+ onMount(() => {
296
+ document.addEventListener('mousedown', handleClickOutside);
297
+ return () => {
298
+ document.removeEventListener('mousedown', handleClickOutside);
299
+ clearTimeout(searchTimer);
300
+ if (dropdownEl?.parentNode === document.body) {
301
+ document.body.removeChild(dropdownEl);
302
+ }
303
+ };
304
+ });
305
+ </script>
306
+
307
+ <div
308
+ class={cn('sx-phone', `sx-phone-${size}`, disabled && 'sx-phone-disabled', className)}
309
+ data-testid={testId}
310
+ >
311
+ <label for={`${id}-number`} class={cn('sx-phone-label', hideLabel && 'sx-sr-only')}>
312
+ {label}
313
+ {#if required}
314
+ <span class="sx-phone-required" aria-hidden="true">*</span>
315
+ {/if}
316
+ </label>
317
+
318
+ <div
319
+ class={cn(
320
+ 'sx-phone-field',
321
+ isFocused && 'sx-phone-focused',
322
+ showError && 'sx-phone-error-state'
323
+ )}
324
+ >
325
+ <!-- Country selector -->
326
+ <button
327
+ bind:this={triggerEl}
328
+ type="button"
329
+ class="sx-phone-country"
330
+ {disabled}
331
+ aria-haspopup="listbox"
332
+ aria-expanded={isOpen}
333
+ aria-controls={isOpen ? dropdownId : undefined}
334
+ aria-label={`Selected country: ${selectedCountry.name} (${selectedCountry.dial})`}
335
+ onclick={() => { if (isOpen) closeCountryPicker(); else openCountryPicker(); }}
336
+ onkeydown={handleCountryKeydown}
337
+ >
338
+ <span class="sx-phone-flag">{selectedCountry.flag}</span>
339
+ <span class="sx-phone-dial">{selectedCountry.dial}</span>
340
+ <span class="sx-phone-chevron" aria-hidden="true">
341
+ <svg width="10" height="10" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
342
+ <path d="M3 4.5L6 7.5L9 4.5" />
343
+ </svg>
344
+ </span>
345
+ </button>
346
+
347
+ <span class="sx-phone-divider" aria-hidden="true"></span>
348
+
349
+ <!-- Phone number input -->
350
+ <input
351
+ bind:this={inputRef}
352
+ id={`${id}-number`}
353
+ type="tel"
354
+ class="sx-phone-input"
355
+ value={formatNumber(nationalNumber)}
356
+ {placeholder}
357
+ {disabled}
358
+ {required}
359
+ inputmode="tel"
360
+ autocomplete="tel-national"
361
+ aria-describedby={ariaDescribedBy}
362
+ aria-invalid={showError || undefined}
363
+ oninput={handleInput}
364
+ onfocus={handleFocus}
365
+ onblur={handleBlur}
366
+ />
367
+ </div>
368
+
369
+ {#if alwaysShowFooter || hasFooterContent}
370
+ <div class="sx-phone-footer">
371
+ {#if showError}
372
+ <p id={errorId} class="sx-phone-error" role="alert" aria-live="assertive">{effectiveError}</p>
373
+ {:else if hint}
374
+ <p id={hintId} class="sx-phone-hint">{hint}</p>
375
+ {/if}
376
+ </div>
377
+ {/if}
378
+ </div>
379
+
380
+ <!-- Country dropdown -->
381
+ {#if isOpen}
382
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
383
+ <div
384
+ bind:this={dropdownEl}
385
+ id={dropdownId}
386
+ class="sx-phone-dropdown"
387
+ style="position:fixed;left:{panelLeft}px;top:{panelTop}px;width:{panelWidth}px;"
388
+ role="listbox"
389
+ aria-label="Select country"
390
+ tabindex="-1"
391
+ onmousedown={(e) => e.preventDefault()}
392
+ >
393
+ {#each countries as country, i}
394
+ {@const isActive = i === activeIndex}
395
+ {@const isSelected = country.code === selectedCountry.code}
396
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
397
+ <div
398
+ id={`${id}-country-${country.code}`}
399
+ class={cn(
400
+ 'sx-phone-country-option',
401
+ isActive && 'sx-phone-country-active',
402
+ isSelected && 'sx-phone-country-selected'
403
+ )}
404
+ role="option"
405
+ tabindex="-1"
406
+ aria-selected={isSelected}
407
+ onmouseenter={() => { activeIndex = i; }}
408
+ onmousedown={(e) => { e.preventDefault(); selectCountry(country); }}
409
+ >
410
+ <span class="sx-phone-country-flag">{country.flag}</span>
411
+ <span class="sx-phone-country-name">{country.name}</span>
412
+ <span class="sx-phone-country-dial">{country.dial}</span>
413
+ </div>
414
+ {/each}
415
+ </div>
416
+ {/if}
417
+
418
+ <style>
419
+ .sx-phone {
420
+ display: flex;
421
+ flex-direction: column;
422
+ gap: var(--sx-space-1);
423
+ font-family: var(--sx-font-body);
424
+ }
425
+
426
+ .sx-phone-disabled { opacity: 0.5; }
427
+
428
+ .sx-phone-label {
429
+ font-size: var(--sx-text-sm);
430
+ font-weight: 500;
431
+ color: var(--sx-color-text-secondary);
432
+ }
433
+
434
+ .sx-phone-required { color: var(--sx-color-red); margin-left: 2px; }
435
+
436
+ .sx-sr-only {
437
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
438
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
439
+ }
440
+
441
+ /* Field wrapper */
442
+ .sx-phone-field {
443
+ display: flex;
444
+ align-items: center;
445
+ border: 1px solid var(--sx-color-border-strong);
446
+ border-radius: var(--sx-radius-md);
447
+ background: var(--sx-color-surface);
448
+ transition: border-color var(--sx-transition-fast), box-shadow var(--sx-transition-fast);
449
+ box-shadow:
450
+ inset 0 1px 3px rgba(0, 0, 0, 0.3),
451
+ inset 0 0 0 1px rgba(0, 0, 0, 0.06);
452
+ }
453
+
454
+ .sx-phone-field:hover:not(.sx-phone-focused):not(.sx-phone-error-state) {
455
+ border-color: var(--sx-color-border-hover);
456
+ }
457
+
458
+ .sx-phone-focused {
459
+ border-color: var(--sx-color-primary);
460
+ box-shadow:
461
+ inset 0 1px 2px rgba(0, 0, 0, 0.2),
462
+ 0 0 0 3px var(--sx-color-primary-ring);
463
+ }
464
+
465
+ .sx-phone-error-state {
466
+ border-color: var(--sx-color-red);
467
+ box-shadow:
468
+ inset 0 1px 2px rgba(0, 0, 0, 0.2),
469
+ 0 0 0 3px var(--sx-color-red-ring);
470
+ }
471
+
472
+ .sx-phone-sm .sx-phone-field { min-height: 32px; }
473
+ .sx-phone-md .sx-phone-field { min-height: 40px; }
474
+ .sx-phone-lg .sx-phone-field { min-height: 48px; }
475
+
476
+ /* Country button */
477
+ .sx-phone-country {
478
+ display: flex;
479
+ align-items: center;
480
+ gap: var(--sx-space-1);
481
+ padding: 0 var(--sx-space-2) 0 var(--sx-space-3);
482
+ border: none;
483
+ background: transparent;
484
+ color: var(--sx-color-text);
485
+ cursor: pointer;
486
+ font-family: var(--sx-font-body);
487
+ font-size: var(--sx-text-sm);
488
+ white-space: nowrap;
489
+ height: 100%;
490
+ }
491
+
492
+ .sx-phone-country:focus-visible {
493
+ outline: 2px solid var(--sx-color-primary);
494
+ outline-offset: -2px;
495
+ border-radius: var(--sx-radius-md) 0 0 var(--sx-radius-md);
496
+ }
497
+
498
+ .sx-phone-flag { font-size: 1.1em; }
499
+ .sx-phone-dial { color: var(--sx-color-text-secondary); font-weight: 500; font-size: var(--sx-text-xs); }
500
+ .sx-phone-chevron { color: var(--sx-color-text-disabled); display: flex; align-items: center; }
501
+
502
+ .sx-phone-divider {
503
+ width: 1px;
504
+ height: 20px;
505
+ background: var(--sx-color-border);
506
+ flex-shrink: 0;
507
+ }
508
+
509
+ /* Phone input */
510
+ .sx-phone-input {
511
+ flex: 1;
512
+ min-width: 0;
513
+ border: none;
514
+ background: transparent;
515
+ color: var(--sx-color-text);
516
+ font-family: var(--sx-font-body);
517
+ font-size: var(--sx-text-sm);
518
+ outline: none;
519
+ padding: var(--sx-space-2) var(--sx-space-3);
520
+ }
521
+
522
+ .sx-phone-input:focus-visible { box-shadow: none; }
523
+ .sx-phone-input::placeholder { color: var(--sx-color-text-disabled); }
524
+
525
+ /* Footer */
526
+ .sx-phone-footer { min-height: 1.25rem; }
527
+ .sx-phone-error { font-size: var(--sx-text-xs); font-weight: 500; color: var(--sx-color-red); margin: 0; }
528
+ .sx-phone-hint { font-size: var(--sx-text-xs); color: var(--sx-color-text-secondary); margin: 0; }
529
+
530
+ /* Country dropdown */
531
+ .sx-phone-dropdown {
532
+ z-index: var(--sx-z-dropdown);
533
+ max-height: 260px;
534
+ overflow-y: auto;
535
+ background: var(--sx-color-surface-2);
536
+ border: 1px solid var(--sx-color-border-strong);
537
+ border-radius: var(--sx-radius-md);
538
+ box-shadow: var(--sx-shadow-lg);
539
+ backdrop-filter: var(--sx-glass-blur);
540
+ -webkit-backdrop-filter: var(--sx-glass-blur);
541
+ padding: var(--sx-space-1) 0;
542
+ font-family: var(--sx-font-body);
543
+ }
544
+
545
+ .sx-phone-country-option {
546
+ display: flex;
547
+ align-items: center;
548
+ gap: var(--sx-space-2);
549
+ padding: var(--sx-space-2) var(--sx-space-3);
550
+ font-size: var(--sx-text-sm);
551
+ color: var(--sx-color-text);
552
+ cursor: pointer;
553
+ user-select: none;
554
+ transition: background var(--sx-transition-fast);
555
+ }
556
+
557
+ .sx-phone-country-active {
558
+ background: var(--sx-color-primary-hover);
559
+ color: var(--sx-color-primary);
560
+ }
561
+
562
+ .sx-phone-country-selected:not(.sx-phone-country-active) {
563
+ background: var(--sx-color-primary-subtle);
564
+ }
565
+
566
+ .sx-phone-country-flag { font-size: 1.1em; flex-shrink: 0; }
567
+
568
+ .sx-phone-country-name {
569
+ flex: 1;
570
+ font-weight: 500;
571
+ overflow: hidden;
572
+ text-overflow: ellipsis;
573
+ white-space: nowrap;
574
+ }
575
+
576
+ .sx-phone-country-dial {
577
+ flex-shrink: 0;
578
+ font-family: var(--sx-font-mono);
579
+ font-size: var(--sx-text-xs);
580
+ color: var(--sx-color-text-secondary);
581
+ }
582
+
583
+ .sx-phone-country-active .sx-phone-country-dial {
584
+ color: var(--sx-color-primary);
585
+ opacity: 0.7;
586
+ }
587
+
588
+ @media (prefers-reduced-motion: reduce) {
589
+ .sx-phone-field, .sx-phone-country-option { transition: none; }
590
+ }
591
+ </style>