@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.
- 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 +30 -0
- 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,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 @@
|
|
|
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>
|