@marianmeres/stuic 3.23.3 → 3.24.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.
@@ -33,6 +33,7 @@
33
33
  autoColor?: boolean;
34
34
  /** CSS class override */
35
35
  class?: string;
36
+ classInner?: string;
36
37
  /** Bindable element reference */
37
38
  el?: HTMLDivElement | HTMLButtonElement;
38
39
  }
@@ -57,6 +58,7 @@
57
58
  textColor,
58
59
  autoColor = false,
59
60
  class: classProp,
61
+ classInner,
60
62
  el = $bindable(),
61
63
  }: Props = $props();
62
64
 
@@ -187,6 +189,8 @@
187
189
  )
188
190
  );
189
191
 
192
+ let _classInner = $derived(twMerge("inline-block", classInner));
193
+
190
194
  function handleImageError() {
191
195
  imageError = true;
192
196
  }
@@ -202,13 +206,15 @@
202
206
  data-size={isPresetSize(size) ? size : undefined}
203
207
  data-interactive="true"
204
208
  >
205
- {#if renderMode === "photo"}
206
- <img {src} {alt} class="size-full object-cover" onerror={handleImageError} />
207
- {:else if renderMode === "initials"}
208
- {fallbackInitials}
209
- {:else}
210
- {@html iconToRender({ size: iconSize })}
211
- {/if}
209
+ <span class={_classInner}>
210
+ {#if renderMode === "photo"}
211
+ <img {src} {alt} class="size-full object-cover" onerror={handleImageError} />
212
+ {:else if renderMode === "initials"}
213
+ {fallbackInitials}
214
+ {:else}
215
+ {@html iconToRender({ size: iconSize })}
216
+ {/if}
217
+ </span>
212
218
  </button>
213
219
  {:else}
214
220
  <div
@@ -217,12 +223,14 @@
217
223
  {style}
218
224
  data-size={isPresetSize(size) ? size : undefined}
219
225
  >
220
- {#if renderMode === "photo"}
221
- <img {src} {alt} class="size-full object-cover" onerror={handleImageError} />
222
- {:else if renderMode === "initials"}
223
- {fallbackInitials}
224
- {:else}
225
- {@html iconToRender({ size: iconSize })}
226
- {/if}
226
+ <span class={_classInner}>
227
+ {#if renderMode === "photo"}
228
+ <img {src} {alt} class="size-full object-cover" onerror={handleImageError} />
229
+ {:else if renderMode === "initials"}
230
+ {fallbackInitials}
231
+ {:else}
232
+ {@html iconToRender({ size: iconSize })}
233
+ {/if}
234
+ </span>
227
235
  </div>
228
236
  {/if}
@@ -34,6 +34,7 @@ export interface Props {
34
34
  autoColor?: boolean;
35
35
  /** CSS class override */
36
36
  class?: string;
37
+ classInner?: string;
37
38
  /** Bindable element reference */
38
39
  el?: HTMLDivElement | HTMLButtonElement;
39
40
  }
@@ -6,6 +6,7 @@
6
6
  CheckoutAddressData,
7
7
  CheckoutValidationError,
8
8
  } from "./_internal/checkout-types.js";
9
+ import type { Props as FieldPhoneNumberProps } from "../Input/FieldPhoneNumber.svelte";
9
10
 
10
11
  export interface Props extends Omit<HTMLAttributes<HTMLFieldSetElement>, "children"> {
11
12
  /** Bindable address data. Default: createEmptyAddress() */
@@ -59,6 +60,9 @@
59
60
  ]
60
61
  >;
61
62
 
63
+ /** Extra props forwarded to the internal FieldPhoneNumber component. */
64
+ phoneFieldProps?: Partial<FieldPhoneNumberProps>;
65
+
62
66
  t?: TranslateFn;
63
67
  unstyled?: boolean;
64
68
  class?: string;
@@ -71,6 +75,8 @@
71
75
  import { t_default } from "./_internal/checkout-i18n-defaults.js";
72
76
  import { createEmptyAddress } from "./_internal/checkout-utils.js";
73
77
  import FieldInput from "../Input/FieldInput.svelte";
78
+ import FieldPhoneNumber from "../Input/FieldPhoneNumber.svelte";
79
+ import { validatePhoneNumber } from "../Input/phone-validation.js";
74
80
 
75
81
  const DEFAULT_REQUIRED = ["name", "street", "city", "postal_code", "country"];
76
82
 
@@ -81,6 +87,7 @@
81
87
  fields,
82
88
  requiredFields = DEFAULT_REQUIRED,
83
89
  countryField,
90
+ phoneFieldProps,
84
91
  t: tProp,
85
92
  unstyled = false,
86
93
  class: classProp,
@@ -229,21 +236,20 @@
229
236
 
230
237
  <!-- Phone (full width, block label) -->
231
238
  {#if fields?.phone !== false}
232
- <!-- svelte-ignore binding_property_non_reactive -->
233
- <FieldInput
234
- bind:value={address.phone}
239
+ <FieldPhoneNumber
240
+ value={address.phone ?? ""}
241
+ onChange={(v) => { address.phone = v; }}
235
242
  label={t("checkout.address.phone_label")}
236
- type="tel"
237
243
  placeholder={t("checkout.address.phone_placeholder")}
238
244
  required={isRequired("phone")}
239
245
  name="{label}-phone"
240
- id="{label}-phone"
241
246
  labelLeftBreakpoint={0}
242
247
  validate={{
243
248
  customValidator(val) {
244
- return fieldError("phone") || "";
249
+ return fieldError("phone") || validatePhoneNumber(val) || "";
245
250
  },
246
251
  }}
252
+ {...phoneFieldProps}
247
253
  />
248
254
  {/if}
249
255
  </fieldset>
@@ -2,6 +2,7 @@ import type { Snippet } from "svelte";
2
2
  import type { HTMLAttributes } from "svelte/elements";
3
3
  import type { TranslateFn } from "../../types.js";
4
4
  import type { CheckoutAddressData, CheckoutValidationError } from "./_internal/checkout-types.js";
5
+ import type { Props as FieldPhoneNumberProps } from "../Input/FieldPhoneNumber.svelte";
5
6
  export interface Props extends Omit<HTMLAttributes<HTMLFieldSetElement>, "children"> {
6
7
  /** Bindable address data. Default: createEmptyAddress() */
7
8
  address?: CheckoutAddressData;
@@ -46,6 +47,8 @@ export interface Props extends Omit<HTMLAttributes<HTMLFieldSetElement>, "childr
46
47
  id: string;
47
48
  }
48
49
  ]>;
50
+ /** Extra props forwarded to the internal FieldPhoneNumber component. */
51
+ phoneFieldProps?: Partial<FieldPhoneNumberProps>;
49
52
  t?: TranslateFn;
50
53
  unstyled?: boolean;
51
54
  class?: string;
@@ -7,6 +7,7 @@
7
7
  CheckoutValidationError,
8
8
  } from "./_internal/checkout-types.js";
9
9
  import type { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
10
+ import type { Props as FieldPhoneNumberProps } from "../Input/FieldPhoneNumber.svelte";
10
11
 
11
12
  export interface Props extends Omit<HTMLAttributes<HTMLFormElement>, "children"> {
12
13
  /** Bindable form data. Default: createEmptyCustomerFormData() */
@@ -40,6 +41,9 @@
40
41
  vat_number?: boolean;
41
42
  };
42
43
 
44
+ /** Extra props forwarded to the internal FieldPhoneNumber component. */
45
+ phoneFieldProps?: Partial<FieldPhoneNumberProps>;
46
+
43
47
  /** Override the CTA button label. Takes precedence over i18n. */
44
48
  submitLabel?: string;
45
49
 
@@ -74,6 +78,7 @@
74
78
  } from "./_internal/checkout-utils.js";
75
79
  import Button from "../Button/Button.svelte";
76
80
  import FieldInput from "../Input/FieldInput.svelte";
81
+ import FieldPhoneNumber from "../Input/FieldPhoneNumber.svelte";
77
82
 
78
83
  let {
79
84
  formData = $bindable(createEmptyCustomerFormData()),
@@ -84,6 +89,7 @@
84
89
  showB2bFields = true,
85
90
  b2bExpanded = false,
86
91
  fields,
92
+ phoneFieldProps,
87
93
  submitLabel,
88
94
  submittingLabel,
89
95
  submitButton,
@@ -195,13 +201,13 @@
195
201
  <!-- Phone -->
196
202
  {#if fields?.phone !== false}
197
203
  <!-- svelte-ignore binding_property_non_reactive -->
198
- <FieldInput
204
+ <FieldPhoneNumber
199
205
  bind:value={formData.phone}
200
206
  label={t("checkout.guest.phone_label")}
201
- type="tel"
202
207
  placeholder={t("checkout.guest.phone_placeholder")}
203
208
  name="checkout-guest-phone"
204
209
  labelLeftBreakpoint={0}
210
+ {...phoneFieldProps}
205
211
  />
206
212
  {/if}
207
213
 
@@ -3,6 +3,7 @@ import type { HTMLAttributes } from "svelte/elements";
3
3
  import type { TranslateFn } from "../../types.js";
4
4
  import type { CheckoutCustomerFormData, CheckoutValidationError } from "./_internal/checkout-types.js";
5
5
  import type { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
6
+ import type { Props as FieldPhoneNumberProps } from "../Input/FieldPhoneNumber.svelte";
6
7
  export interface Props extends Omit<HTMLAttributes<HTMLFormElement>, "children"> {
7
8
  /** Bindable form data. Default: createEmptyCustomerFormData() */
8
9
  formData?: CheckoutCustomerFormData;
@@ -28,6 +29,8 @@ export interface Props extends Omit<HTMLAttributes<HTMLFormElement>, "children">
28
29
  tax_id?: boolean;
29
30
  vat_number?: boolean;
30
31
  };
32
+ /** Extra props forwarded to the internal FieldPhoneNumber component. */
33
+ phoneFieldProps?: Partial<FieldPhoneNumberProps>;
31
34
  /** Override the CTA button label. Takes precedence over i18n. */
32
35
  submitLabel?: string;
33
36
  /** Override the CTA button label while submitting. */
@@ -254,12 +254,12 @@
254
254
 
255
255
  .stuic-dropdown-menu-search {
256
256
  position: sticky;
257
- top: 0;
257
+ top: calc(var(--stuic-dropdown-menu-padding) * -1);
258
258
  z-index: 10;
259
259
  display: flex;
260
260
  align-items: center;
261
261
  gap: calc(var(--spacing) * 1);
262
- padding: calc(var(--spacing) * 1.5) calc(var(--spacing) * 2);
262
+ padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2);
263
263
  background: var(--stuic-dropdown-menu-search-bg);
264
264
  border-bottom: 1px solid var(--stuic-dropdown-menu-search-border);
265
265
  margin: calc(var(--stuic-dropdown-menu-padding) * -1);
@@ -0,0 +1,338 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+ import type { ValidateOptions } from "../../actions/validate.svelte.js";
4
+ import type { TranslateFn } from "../../types.js";
5
+ import type { THC } from "../Thc/Thc.svelte";
6
+
7
+ type SnippetWithId = Snippet<[{ id: string }]>;
8
+
9
+ export interface Props extends Record<string, any> {
10
+ input?: HTMLInputElement;
11
+ /** Full phone number string, e.g. "+421905123456". Bindable. */
12
+ value?: string;
13
+ /** Selected country ISO code, e.g. "SK". Bindable. */
14
+ country?: string;
15
+ /** The dial code part with leading +, e.g. "+421". Bindable (read-derived). */
16
+ dialCode?: string;
17
+ /** The local number part, e.g. "905123456". Bindable. */
18
+ localNumber?: string;
19
+ /** ISO code for initial country selection when value is empty. */
20
+ defaultCountry?: string;
21
+ /** Show country flag emoji. Default: true. */
22
+ flags?: boolean;
23
+ /** Filtered list of countries to show (ISO codes). If undefined, show all. */
24
+ countries?: string[];
25
+ /** ISO codes of countries to pin at the top of the dropdown list. */
26
+ preferredCountries?: string[];
27
+ /** Placeholder for the tel input. */
28
+ placeholder?: string;
29
+ /** Name attribute for hidden input (form submission). */
30
+ name?: string;
31
+ //
32
+ label?: SnippetWithId | THC;
33
+ description?: SnippetWithId | THC;
34
+ class?: string;
35
+ id?: string;
36
+ tabindex?: number;
37
+ renderSize?: "sm" | "md" | "lg" | string;
38
+ required?: boolean;
39
+ disabled?: boolean;
40
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
41
+ labelAfter?: SnippetWithId | THC;
42
+ inputAfter?: SnippetWithId | THC;
43
+ inputBelow?: SnippetWithId | THC;
44
+ below?: SnippetWithId | THC;
45
+ labelLeft?: boolean;
46
+ labelLeftWidth?: "normal" | "wide";
47
+ labelLeftBreakpoint?: number;
48
+ //
49
+ classInput?: string;
50
+ classLabel?: string;
51
+ classLabelBox?: string;
52
+ classInputBox?: string;
53
+ classInputBoxWrap?: string;
54
+ classInputBoxWrapInvalid?: string;
55
+ classDescBox?: string;
56
+ classBelowBox?: string;
57
+ classPrefixTrigger?: string;
58
+ classPrefixDropdown?: string;
59
+ style?: string;
60
+ //
61
+ t?: TranslateFn;
62
+ onChange?: (value: string) => void;
63
+ }
64
+ </script>
65
+
66
+ <script lang="ts">
67
+ import { tick } from "svelte";
68
+ import {
69
+ validate as validateAction,
70
+ type ValidationResult,
71
+ } from "../../actions/validate.svelte.js";
72
+ import { getId } from "../../utils/get-id.js";
73
+ import { twMerge } from "../../utils/tw-merge.js";
74
+ import InputWrap from "./_internal/InputWrap.svelte";
75
+ import PhonePrefixPicker from "./_internal/PhonePrefixPicker.svelte";
76
+ import {
77
+ COUNTRIES,
78
+ ISO_MAP,
79
+ DIAL_CODES_DESC,
80
+ DIAL_CODE_MAP,
81
+ type Country,
82
+ } from "./_internal/countries.js";
83
+ import { validatePhoneNumber } from "./phone-validation.js";
84
+
85
+ let {
86
+ input = $bindable(),
87
+ value = $bindable(""),
88
+ country = $bindable(""),
89
+ dialCode = $bindable(""),
90
+ localNumber = $bindable(""),
91
+ defaultCountry,
92
+ flags = true,
93
+ countries: allowedCountries,
94
+ preferredCountries,
95
+ placeholder,
96
+ name,
97
+ //
98
+ label,
99
+ description,
100
+ class: classProp,
101
+ id = getId(),
102
+ tabindex = 0,
103
+ renderSize = "md",
104
+ required = false,
105
+ disabled = false,
106
+ validate,
107
+ //
108
+ labelAfter,
109
+ inputAfter,
110
+ inputBelow,
111
+ below,
112
+ //
113
+ labelLeft = false,
114
+ labelLeftWidth = "normal",
115
+ labelLeftBreakpoint = 480,
116
+ //
117
+ classInput,
118
+ classLabel,
119
+ classLabelBox,
120
+ classInputBox,
121
+ classInputBoxWrap,
122
+ classInputBoxWrapInvalid,
123
+ classDescBox,
124
+ classBelowBox,
125
+ classPrefixTrigger,
126
+ classPrefixDropdown,
127
+ style,
128
+ //
129
+ t: tProp,
130
+ onChange,
131
+ ...rest
132
+ }: Props = $props();
133
+
134
+ let hiddenInputEl: HTMLInputElement | undefined = $state();
135
+
136
+ // Validation state
137
+ let validation: ValidationResult | undefined = $state();
138
+ const setValidationResult = (res: ValidationResult) => (validation = res);
139
+
140
+ // Filtered country list
141
+ let countryList = $derived.by(() => {
142
+ if (!allowedCountries) return COUNTRIES;
143
+ const set = new Set(allowedCountries.map((c) => c.toUpperCase()));
144
+ return COUNTRIES.filter((c) => set.has(c.iso));
145
+ });
146
+
147
+ // Selected country object (initialize once from defaultCountry prop)
148
+ const _initCountry = defaultCountry;
149
+ let selectedCountry: Country | undefined = $state(
150
+ _initCountry ? ISO_MAP.get(_initCountry.toUpperCase()) : undefined
151
+ );
152
+
153
+ // Internal local number (for controlled input)
154
+ let _localNumber = $state("");
155
+
156
+ // Flag to prevent circular sync
157
+ let _syncing = false;
158
+
159
+ // Parse value into parts when value changes externally
160
+ $effect(() => {
161
+ const v = value;
162
+ const defCountry = defaultCountry;
163
+ if (_syncing) return;
164
+
165
+ if (!v) {
166
+ if (!selectedCountry && defCountry) {
167
+ selectedCountry = ISO_MAP.get(defCountry.toUpperCase());
168
+ }
169
+ _localNumber = "";
170
+ syncDerived();
171
+ return;
172
+ }
173
+
174
+ if (v.startsWith("+")) {
175
+ const digits = v.slice(1);
176
+ for (const code of DIAL_CODES_DESC) {
177
+ if (digits.startsWith(code)) {
178
+ const matched = DIAL_CODE_MAP.get(code);
179
+ if (matched?.length) {
180
+ // Preserve current selection if it shares the same dial code
181
+ if (!selectedCountry || selectedCountry.dialCode !== code) {
182
+ selectedCountry = matched[0];
183
+ }
184
+ _localNumber = digits.slice(code.length);
185
+ syncDerived();
186
+ return;
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ // Fallback: treat entire value as local number
193
+ _localNumber = v.replace(/^\+/, "");
194
+ syncDerived();
195
+ });
196
+
197
+ // Sync convenience bindable props
198
+ function syncDerived() {
199
+ dialCode = selectedCountry ? `+${selectedCountry.dialCode}` : "";
200
+ country = selectedCountry?.iso ?? "";
201
+ localNumber = _localNumber;
202
+ }
203
+
204
+ // Compose full value from parts
205
+ function composeValue(): string {
206
+ const digits = _localNumber.replace(/\D/g, "");
207
+ if (!digits) return "";
208
+ if (selectedCountry) return `+${selectedCountry.dialCode}${digits}`;
209
+ return digits;
210
+ }
211
+
212
+ // Sync internal -> external value
213
+ function syncToValue() {
214
+ _syncing = true;
215
+ value = composeValue();
216
+ syncDerived();
217
+ tick().then(() => {
218
+ hiddenInputEl?.dispatchEvent(new Event("change", { bubbles: true }));
219
+ _syncing = false;
220
+ });
221
+ onChange?.(value);
222
+ }
223
+
224
+ // Handle country selection from picker
225
+ function onCountrySelect(c: Country) {
226
+ selectedCountry = c;
227
+ syncToValue();
228
+ input?.focus();
229
+ }
230
+
231
+ // Handle paste: detect international prefix
232
+ function handlePaste(e: ClipboardEvent) {
233
+ const pasted = e.clipboardData?.getData("text") ?? "";
234
+ if (pasted.startsWith("+") || pasted.startsWith("00")) {
235
+ e.preventDefault();
236
+ const normalized = pasted.startsWith("00") ? "+" + pasted.slice(2) : pasted;
237
+ const digits = normalized.slice(1).replace(/\D/g, "");
238
+ for (const code of DIAL_CODES_DESC) {
239
+ if (digits.startsWith(code)) {
240
+ const matched = DIAL_CODE_MAP.get(code);
241
+ if (matched?.length) {
242
+ // Preserve existing selection if matching dial code
243
+ if (!selectedCountry || selectedCountry.dialCode !== code) {
244
+ selectedCountry = matched[0];
245
+ }
246
+ _localNumber = digits.slice(code.length);
247
+ syncToValue();
248
+ return;
249
+ }
250
+ }
251
+ }
252
+ // No recognized prefix
253
+ _localNumber = digits;
254
+ syncToValue();
255
+ }
256
+ }
257
+
258
+ // Handle local number input change
259
+ function handleInput(e: Event) {
260
+ _localNumber = (e.currentTarget as HTMLInputElement).value;
261
+ syncToValue();
262
+ }
263
+ </script>
264
+
265
+ <InputWrap
266
+ {id}
267
+ {label}
268
+ {description}
269
+ {labelAfter}
270
+ {inputAfter}
271
+ {inputBelow}
272
+ {below}
273
+ {required}
274
+ {disabled}
275
+ size={renderSize}
276
+ class={classProp}
277
+ {labelLeft}
278
+ {labelLeftWidth}
279
+ {labelLeftBreakpoint}
280
+ {classLabel}
281
+ {classLabelBox}
282
+ {classInputBox}
283
+ {classInputBoxWrap}
284
+ {classInputBoxWrapInvalid}
285
+ {classDescBox}
286
+ {classBelowBox}
287
+ {validation}
288
+ {style}
289
+ >
290
+ {#snippet inputBefore()}
291
+ <PhonePrefixPicker
292
+ {selectedCountry}
293
+ {countryList}
294
+ {preferredCountries}
295
+ {flags}
296
+ {disabled}
297
+ classTrigger={classPrefixTrigger}
298
+ classDropdown={classPrefixDropdown}
299
+ onSelect={onCountrySelect}
300
+ t={tProp}
301
+ />
302
+ {/snippet}
303
+
304
+ <input
305
+ bind:this={input}
306
+ type="tel"
307
+ inputmode="tel"
308
+ value={_localNumber}
309
+ oninput={handleInput}
310
+ onpaste={handlePaste}
311
+ {id}
312
+ {tabindex}
313
+ {required}
314
+ {disabled}
315
+ {placeholder}
316
+ class={twMerge(classInput)}
317
+ {...rest}
318
+ />
319
+ </InputWrap>
320
+
321
+ <!-- Hidden input for form submission and validation -->
322
+ {#if name}
323
+ <input
324
+ type="hidden"
325
+ {name}
326
+ value={value ?? ""}
327
+ bind:this={hiddenInputEl}
328
+ use:validateAction={() => {
329
+ const customOpts = typeof validate === "object" && validate ? validate : {};
330
+ return {
331
+ enabled: validate !== false,
332
+ ...customOpts,
333
+ customValidator: customOpts.customValidator ?? validatePhoneNumber,
334
+ setValidationResult,
335
+ };
336
+ }}
337
+ />
338
+ {/if}
@@ -0,0 +1,62 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { ValidateOptions } from "../../actions/validate.svelte.js";
3
+ import type { TranslateFn } from "../../types.js";
4
+ import type { THC } from "../Thc/Thc.svelte";
5
+ type SnippetWithId = Snippet<[{
6
+ id: string;
7
+ }]>;
8
+ export interface Props extends Record<string, any> {
9
+ input?: HTMLInputElement;
10
+ /** Full phone number string, e.g. "+421905123456". Bindable. */
11
+ value?: string;
12
+ /** Selected country ISO code, e.g. "SK". Bindable. */
13
+ country?: string;
14
+ /** The dial code part with leading +, e.g. "+421". Bindable (read-derived). */
15
+ dialCode?: string;
16
+ /** The local number part, e.g. "905123456". Bindable. */
17
+ localNumber?: string;
18
+ /** ISO code for initial country selection when value is empty. */
19
+ defaultCountry?: string;
20
+ /** Show country flag emoji. Default: true. */
21
+ flags?: boolean;
22
+ /** Filtered list of countries to show (ISO codes). If undefined, show all. */
23
+ countries?: string[];
24
+ /** ISO codes of countries to pin at the top of the dropdown list. */
25
+ preferredCountries?: string[];
26
+ /** Placeholder for the tel input. */
27
+ placeholder?: string;
28
+ /** Name attribute for hidden input (form submission). */
29
+ name?: string;
30
+ label?: SnippetWithId | THC;
31
+ description?: SnippetWithId | THC;
32
+ class?: string;
33
+ id?: string;
34
+ tabindex?: number;
35
+ renderSize?: "sm" | "md" | "lg" | string;
36
+ required?: boolean;
37
+ disabled?: boolean;
38
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
39
+ labelAfter?: SnippetWithId | THC;
40
+ inputAfter?: SnippetWithId | THC;
41
+ inputBelow?: SnippetWithId | THC;
42
+ below?: SnippetWithId | THC;
43
+ labelLeft?: boolean;
44
+ labelLeftWidth?: "normal" | "wide";
45
+ labelLeftBreakpoint?: number;
46
+ classInput?: string;
47
+ classLabel?: string;
48
+ classLabelBox?: string;
49
+ classInputBox?: string;
50
+ classInputBoxWrap?: string;
51
+ classInputBoxWrapInvalid?: string;
52
+ classDescBox?: string;
53
+ classBelowBox?: string;
54
+ classPrefixTrigger?: string;
55
+ classPrefixDropdown?: string;
56
+ style?: string;
57
+ t?: TranslateFn;
58
+ onChange?: (value: string) => void;
59
+ }
60
+ declare const FieldPhoneNumber: import("svelte").Component<Props, {}, "value" | "input" | "country" | "dialCode" | "localNumber">;
61
+ type FieldPhoneNumber = ReturnType<typeof FieldPhoneNumber>;
62
+ export default FieldPhoneNumber;
@@ -0,0 +1,118 @@
1
+ <script lang="ts">
2
+ import { twMerge } from "../../../utils/tw-merge.js";
3
+ import { iconChevronDown } from "../../../icons/index.js";
4
+ import DropdownMenu, {
5
+ type DropdownMenuActionItem,
6
+ type DropdownMenuItem,
7
+ type DropdownMenuSearchConfig,
8
+ } from "../../DropdownMenu/DropdownMenu.svelte";
9
+ import { type Country, ISO_MAP } from "./countries.js";
10
+
11
+ interface Props {
12
+ selectedCountry?: Country;
13
+ countryList: Country[];
14
+ preferredCountries?: string[];
15
+ flags?: boolean;
16
+ disabled?: boolean;
17
+ classTrigger?: string;
18
+ classDropdown?: string;
19
+ onSelect: (country: Country) => void;
20
+ t?: (k: string, v?: any, f?: any) => string;
21
+ }
22
+
23
+ let {
24
+ selectedCountry,
25
+ countryList,
26
+ preferredCountries,
27
+ flags = true,
28
+ disabled = false,
29
+ classTrigger,
30
+ classDropdown,
31
+ onSelect,
32
+ t,
33
+ }: Props = $props();
34
+
35
+ let isOpen = $state(false);
36
+
37
+ function countryToItem(c: Country): DropdownMenuActionItem {
38
+ const prefix = flags ? `${c.flag} ` : "";
39
+ return {
40
+ type: "action",
41
+ id: c.iso,
42
+ label: `${prefix}${c.name} (+${c.dialCode})`,
43
+ onSelect: () => {
44
+ onSelect(c);
45
+ isOpen = false;
46
+ },
47
+ };
48
+ }
49
+
50
+ let items: DropdownMenuItem[] = $derived.by(() => {
51
+ const result: DropdownMenuItem[] = [];
52
+ const preferredSet = new Set(preferredCountries?.map((c) => c.toUpperCase()) ?? []);
53
+
54
+ if (preferredSet.size > 0) {
55
+ const preferred = countryList.filter((c) => preferredSet.has(c.iso));
56
+ preferred.forEach((c) => result.push(countryToItem(c)));
57
+ if (preferred.length > 0) {
58
+ result.push({ type: "divider" });
59
+ }
60
+ }
61
+
62
+ const rest =
63
+ preferredSet.size > 0
64
+ ? countryList.filter((c) => !preferredSet.has(c.iso))
65
+ : countryList;
66
+ rest.forEach((c) => result.push(countryToItem(c)));
67
+
68
+ return result;
69
+ });
70
+
71
+ let searchConfig: DropdownMenuSearchConfig = $derived({
72
+ placeholder: t?.("phone_search_country") || "Search country...",
73
+ strategy: "prefix",
74
+ getContent: (item) => {
75
+ const c = ISO_MAP.get(String(item.id));
76
+ if (!c) return String(item.id);
77
+ return `${c.name} ${c.iso} +${c.dialCode} ${c.dialCode}`;
78
+ },
79
+ autoFocus: true,
80
+ noResultsMessage: t?.("phone_no_country_found") || "No country found",
81
+ });
82
+
83
+ let triggerText = $derived.by(() => {
84
+ if (!selectedCountry) return "+?";
85
+ const prefix = flags ? `${selectedCountry.flag} ` : "";
86
+ return `${prefix}+${selectedCountry.dialCode}`;
87
+ });
88
+ </script>
89
+
90
+ <DropdownMenu
91
+ {items}
92
+ bind:isOpen
93
+ position="bottom-span-right"
94
+ search={searchConfig}
95
+ maxHeight="300px"
96
+ closeOnSelect
97
+ class="stuic-phone-prefix-picker"
98
+ classDropdown={twMerge("min-w-64", classDropdown)}
99
+ >
100
+ {#snippet trigger({ toggle, triggerProps })}
101
+ <button
102
+ type="button"
103
+ class={twMerge(
104
+ "stuic-phone-prefix-trigger",
105
+ "flex items-center gap-1 shrink-0 px-2 whitespace-nowrap cursor-pointer",
106
+ classTrigger
107
+ )}
108
+ onclick={toggle}
109
+ {disabled}
110
+ {...triggerProps}
111
+ >
112
+ <span>{triggerText}</span>
113
+ <span class={twMerge("transition-transform duration-150", isOpen && "rotate-180")}>
114
+ {@html iconChevronDown({ size: 14 })}
115
+ </span>
116
+ </button>
117
+ {/snippet}
118
+ </DropdownMenu>
@@ -0,0 +1,15 @@
1
+ import { type Country } from "./countries.js";
2
+ interface Props {
3
+ selectedCountry?: Country;
4
+ countryList: Country[];
5
+ preferredCountries?: string[];
6
+ flags?: boolean;
7
+ disabled?: boolean;
8
+ classTrigger?: string;
9
+ classDropdown?: string;
10
+ onSelect: (country: Country) => void;
11
+ t?: (k: string, v?: any, f?: any) => string;
12
+ }
13
+ declare const PhonePrefixPicker: import("svelte").Component<Props, {}, "">;
14
+ type PhonePrefixPicker = ReturnType<typeof PhonePrefixPicker>;
15
+ export default PhonePrefixPicker;
@@ -0,0 +1,20 @@
1
+ export interface Country {
2
+ /** ISO 3166-1 alpha-2 code, e.g. "SK" */
3
+ iso: string;
4
+ /** Country name in English, e.g. "Slovakia" */
5
+ name: string;
6
+ /** Dial code without leading +, e.g. "421" */
7
+ dialCode: string;
8
+ /** Country flag emoji, e.g. "🇸🇰" */
9
+ flag: string;
10
+ }
11
+ export declare const COUNTRIES: Country[];
12
+ /** Lookup map: ISO code -> Country */
13
+ export declare const ISO_MAP: Map<string, Country>;
14
+ /** Lookup map: dial code -> Country[]. Multiple countries can share a code (e.g. +1). */
15
+ export declare const DIAL_CODE_MAP: Map<string, Country[]>;
16
+ /**
17
+ * Sorted list of unique dial codes (longest first) for prefix detection during paste.
18
+ * Longest-first ensures we match "+1684" (American Samoa) before "+1" (US).
19
+ */
20
+ export declare const DIAL_CODES_DESC: string[];
@@ -0,0 +1,249 @@
1
+ // prettier-ignore
2
+ export const COUNTRIES = [
3
+ { iso: "AF", name: "Afghanistan", dialCode: "93", flag: "🇦🇫" },
4
+ { iso: "AL", name: "Albania", dialCode: "355", flag: "🇦🇱" },
5
+ { iso: "DZ", name: "Algeria", dialCode: "213", flag: "🇩🇿" },
6
+ { iso: "AS", name: "American Samoa", dialCode: "1684", flag: "🇦🇸" },
7
+ { iso: "AD", name: "Andorra", dialCode: "376", flag: "🇦🇩" },
8
+ { iso: "AO", name: "Angola", dialCode: "244", flag: "🇦🇴" },
9
+ { iso: "AI", name: "Anguilla", dialCode: "1264", flag: "🇦🇮" },
10
+ { iso: "AG", name: "Antigua and Barbuda", dialCode: "1268", flag: "🇦🇬" },
11
+ { iso: "AR", name: "Argentina", dialCode: "54", flag: "🇦🇷" },
12
+ { iso: "AM", name: "Armenia", dialCode: "374", flag: "🇦🇲" },
13
+ { iso: "AW", name: "Aruba", dialCode: "297", flag: "🇦🇼" },
14
+ { iso: "AU", name: "Australia", dialCode: "61", flag: "🇦🇺" },
15
+ { iso: "AT", name: "Austria", dialCode: "43", flag: "🇦🇹" },
16
+ { iso: "AZ", name: "Azerbaijan", dialCode: "994", flag: "🇦🇿" },
17
+ { iso: "BS", name: "Bahamas", dialCode: "1242", flag: "🇧🇸" },
18
+ { iso: "BH", name: "Bahrain", dialCode: "973", flag: "🇧🇭" },
19
+ { iso: "BD", name: "Bangladesh", dialCode: "880", flag: "🇧🇩" },
20
+ { iso: "BB", name: "Barbados", dialCode: "1246", flag: "🇧🇧" },
21
+ { iso: "BY", name: "Belarus", dialCode: "375", flag: "🇧🇾" },
22
+ { iso: "BE", name: "Belgium", dialCode: "32", flag: "🇧🇪" },
23
+ { iso: "BZ", name: "Belize", dialCode: "501", flag: "🇧🇿" },
24
+ { iso: "BJ", name: "Benin", dialCode: "229", flag: "🇧🇯" },
25
+ { iso: "BM", name: "Bermuda", dialCode: "1441", flag: "🇧🇲" },
26
+ { iso: "BT", name: "Bhutan", dialCode: "975", flag: "🇧🇹" },
27
+ { iso: "BO", name: "Bolivia", dialCode: "591", flag: "🇧🇴" },
28
+ { iso: "BA", name: "Bosnia and Herzegovina", dialCode: "387", flag: "🇧🇦" },
29
+ { iso: "BW", name: "Botswana", dialCode: "267", flag: "🇧🇼" },
30
+ { iso: "BR", name: "Brazil", dialCode: "55", flag: "🇧🇷" },
31
+ { iso: "IO", name: "British Indian Ocean Territory", dialCode: "246", flag: "🇮🇴" },
32
+ { iso: "VG", name: "British Virgin Islands", dialCode: "1284", flag: "🇻🇬" },
33
+ { iso: "BN", name: "Brunei", dialCode: "673", flag: "🇧🇳" },
34
+ { iso: "BG", name: "Bulgaria", dialCode: "359", flag: "🇧🇬" },
35
+ { iso: "BF", name: "Burkina Faso", dialCode: "226", flag: "🇧🇫" },
36
+ { iso: "BI", name: "Burundi", dialCode: "257", flag: "🇧🇮" },
37
+ { iso: "KH", name: "Cambodia", dialCode: "855", flag: "🇰🇭" },
38
+ { iso: "CM", name: "Cameroon", dialCode: "237", flag: "🇨🇲" },
39
+ { iso: "CA", name: "Canada", dialCode: "1", flag: "🇨🇦" },
40
+ { iso: "CV", name: "Cape Verde", dialCode: "238", flag: "🇨🇻" },
41
+ { iso: "KY", name: "Cayman Islands", dialCode: "1345", flag: "🇰🇾" },
42
+ { iso: "CF", name: "Central African Republic", dialCode: "236", flag: "🇨🇫" },
43
+ { iso: "TD", name: "Chad", dialCode: "235", flag: "🇹🇩" },
44
+ { iso: "CL", name: "Chile", dialCode: "56", flag: "🇨🇱" },
45
+ { iso: "CN", name: "China", dialCode: "86", flag: "🇨🇳" },
46
+ { iso: "CO", name: "Colombia", dialCode: "57", flag: "🇨🇴" },
47
+ { iso: "KM", name: "Comoros", dialCode: "269", flag: "🇰🇲" },
48
+ { iso: "CK", name: "Cook Islands", dialCode: "682", flag: "🇨🇰" },
49
+ { iso: "CR", name: "Costa Rica", dialCode: "506", flag: "🇨🇷" },
50
+ { iso: "HR", name: "Croatia", dialCode: "385", flag: "🇭🇷" },
51
+ { iso: "CU", name: "Cuba", dialCode: "53", flag: "🇨🇺" },
52
+ { iso: "CW", name: "Curaçao", dialCode: "599", flag: "🇨🇼" },
53
+ { iso: "CY", name: "Cyprus", dialCode: "357", flag: "🇨🇾" },
54
+ { iso: "CZ", name: "Czech Republic", dialCode: "420", flag: "🇨🇿" },
55
+ { iso: "CD", name: "Democratic Republic of the Congo", dialCode: "243", flag: "🇨🇩" },
56
+ { iso: "DK", name: "Denmark", dialCode: "45", flag: "🇩🇰" },
57
+ { iso: "DJ", name: "Djibouti", dialCode: "253", flag: "🇩🇯" },
58
+ { iso: "DM", name: "Dominica", dialCode: "1767", flag: "🇩🇲" },
59
+ { iso: "DO", name: "Dominican Republic", dialCode: "1809", flag: "🇩🇴" },
60
+ { iso: "EC", name: "Ecuador", dialCode: "593", flag: "🇪🇨" },
61
+ { iso: "EG", name: "Egypt", dialCode: "20", flag: "🇪🇬" },
62
+ { iso: "SV", name: "El Salvador", dialCode: "503", flag: "🇸🇻" },
63
+ { iso: "GQ", name: "Equatorial Guinea", dialCode: "240", flag: "🇬🇶" },
64
+ { iso: "ER", name: "Eritrea", dialCode: "291", flag: "🇪🇷" },
65
+ { iso: "EE", name: "Estonia", dialCode: "372", flag: "🇪🇪" },
66
+ { iso: "SZ", name: "Eswatini", dialCode: "268", flag: "🇸🇿" },
67
+ { iso: "ET", name: "Ethiopia", dialCode: "251", flag: "🇪🇹" },
68
+ { iso: "FK", name: "Falkland Islands", dialCode: "500", flag: "🇫🇰" },
69
+ { iso: "FO", name: "Faroe Islands", dialCode: "298", flag: "🇫🇴" },
70
+ { iso: "FJ", name: "Fiji", dialCode: "679", flag: "🇫🇯" },
71
+ { iso: "FI", name: "Finland", dialCode: "358", flag: "🇫🇮" },
72
+ { iso: "FR", name: "France", dialCode: "33", flag: "🇫🇷" },
73
+ { iso: "GF", name: "French Guiana", dialCode: "594", flag: "🇬🇫" },
74
+ { iso: "PF", name: "French Polynesia", dialCode: "689", flag: "🇵🇫" },
75
+ { iso: "GA", name: "Gabon", dialCode: "241", flag: "🇬🇦" },
76
+ { iso: "GM", name: "Gambia", dialCode: "220", flag: "🇬🇲" },
77
+ { iso: "GE", name: "Georgia", dialCode: "995", flag: "🇬🇪" },
78
+ { iso: "DE", name: "Germany", dialCode: "49", flag: "🇩🇪" },
79
+ { iso: "GH", name: "Ghana", dialCode: "233", flag: "🇬🇭" },
80
+ { iso: "GI", name: "Gibraltar", dialCode: "350", flag: "🇬🇮" },
81
+ { iso: "GR", name: "Greece", dialCode: "30", flag: "🇬🇷" },
82
+ { iso: "GL", name: "Greenland", dialCode: "299", flag: "🇬🇱" },
83
+ { iso: "GD", name: "Grenada", dialCode: "1473", flag: "🇬🇩" },
84
+ { iso: "GP", name: "Guadeloupe", dialCode: "590", flag: "🇬🇵" },
85
+ { iso: "GU", name: "Guam", dialCode: "1671", flag: "🇬🇺" },
86
+ { iso: "GT", name: "Guatemala", dialCode: "502", flag: "🇬🇹" },
87
+ { iso: "GN", name: "Guinea", dialCode: "224", flag: "🇬🇳" },
88
+ { iso: "GW", name: "Guinea-Bissau", dialCode: "245", flag: "🇬🇼" },
89
+ { iso: "GY", name: "Guyana", dialCode: "592", flag: "🇬🇾" },
90
+ { iso: "HT", name: "Haiti", dialCode: "509", flag: "🇭🇹" },
91
+ { iso: "HN", name: "Honduras", dialCode: "504", flag: "🇭🇳" },
92
+ { iso: "HK", name: "Hong Kong", dialCode: "852", flag: "🇭🇰" },
93
+ { iso: "HU", name: "Hungary", dialCode: "36", flag: "🇭🇺" },
94
+ { iso: "IS", name: "Iceland", dialCode: "354", flag: "🇮🇸" },
95
+ { iso: "IN", name: "India", dialCode: "91", flag: "🇮🇳" },
96
+ { iso: "ID", name: "Indonesia", dialCode: "62", flag: "🇮🇩" },
97
+ { iso: "IR", name: "Iran", dialCode: "98", flag: "🇮🇷" },
98
+ { iso: "IQ", name: "Iraq", dialCode: "964", flag: "🇮🇶" },
99
+ { iso: "IE", name: "Ireland", dialCode: "353", flag: "🇮🇪" },
100
+ { iso: "IL", name: "Israel", dialCode: "972", flag: "🇮🇱" },
101
+ { iso: "IT", name: "Italy", dialCode: "39", flag: "🇮🇹" },
102
+ { iso: "CI", name: "Ivory Coast", dialCode: "225", flag: "🇨🇮" },
103
+ { iso: "JM", name: "Jamaica", dialCode: "1876", flag: "🇯🇲" },
104
+ { iso: "JP", name: "Japan", dialCode: "81", flag: "🇯🇵" },
105
+ { iso: "JO", name: "Jordan", dialCode: "962", flag: "🇯🇴" },
106
+ { iso: "KZ", name: "Kazakhstan", dialCode: "7", flag: "🇰🇿" },
107
+ { iso: "KE", name: "Kenya", dialCode: "254", flag: "🇰🇪" },
108
+ { iso: "KI", name: "Kiribati", dialCode: "686", flag: "🇰🇮" },
109
+ { iso: "XK", name: "Kosovo", dialCode: "383", flag: "🇽🇰" },
110
+ { iso: "KW", name: "Kuwait", dialCode: "965", flag: "🇰🇼" },
111
+ { iso: "KG", name: "Kyrgyzstan", dialCode: "996", flag: "🇰🇬" },
112
+ { iso: "LA", name: "Laos", dialCode: "856", flag: "🇱🇦" },
113
+ { iso: "LV", name: "Latvia", dialCode: "371", flag: "🇱🇻" },
114
+ { iso: "LB", name: "Lebanon", dialCode: "961", flag: "🇱🇧" },
115
+ { iso: "LS", name: "Lesotho", dialCode: "266", flag: "🇱🇸" },
116
+ { iso: "LR", name: "Liberia", dialCode: "231", flag: "🇱🇷" },
117
+ { iso: "LY", name: "Libya", dialCode: "218", flag: "🇱🇾" },
118
+ { iso: "LI", name: "Liechtenstein", dialCode: "423", flag: "🇱🇮" },
119
+ { iso: "LT", name: "Lithuania", dialCode: "370", flag: "🇱🇹" },
120
+ { iso: "LU", name: "Luxembourg", dialCode: "352", flag: "🇱🇺" },
121
+ { iso: "MO", name: "Macau", dialCode: "853", flag: "🇲🇴" },
122
+ { iso: "MG", name: "Madagascar", dialCode: "261", flag: "🇲🇬" },
123
+ { iso: "MW", name: "Malawi", dialCode: "265", flag: "🇲🇼" },
124
+ { iso: "MY", name: "Malaysia", dialCode: "60", flag: "🇲🇾" },
125
+ { iso: "MV", name: "Maldives", dialCode: "960", flag: "🇲🇻" },
126
+ { iso: "ML", name: "Mali", dialCode: "223", flag: "🇲🇱" },
127
+ { iso: "MT", name: "Malta", dialCode: "356", flag: "🇲🇹" },
128
+ { iso: "MH", name: "Marshall Islands", dialCode: "692", flag: "🇲🇭" },
129
+ { iso: "MQ", name: "Martinique", dialCode: "596", flag: "🇲🇶" },
130
+ { iso: "MR", name: "Mauritania", dialCode: "222", flag: "🇲🇷" },
131
+ { iso: "MU", name: "Mauritius", dialCode: "230", flag: "🇲🇺" },
132
+ { iso: "YT", name: "Mayotte", dialCode: "262", flag: "🇾🇹" },
133
+ { iso: "MX", name: "Mexico", dialCode: "52", flag: "🇲🇽" },
134
+ { iso: "FM", name: "Micronesia", dialCode: "691", flag: "🇫🇲" },
135
+ { iso: "MD", name: "Moldova", dialCode: "373", flag: "🇲🇩" },
136
+ { iso: "MC", name: "Monaco", dialCode: "377", flag: "🇲🇨" },
137
+ { iso: "MN", name: "Mongolia", dialCode: "976", flag: "🇲🇳" },
138
+ { iso: "ME", name: "Montenegro", dialCode: "382", flag: "🇲🇪" },
139
+ { iso: "MS", name: "Montserrat", dialCode: "1664", flag: "🇲🇸" },
140
+ { iso: "MA", name: "Morocco", dialCode: "212", flag: "🇲🇦" },
141
+ { iso: "MZ", name: "Mozambique", dialCode: "258", flag: "🇲🇿" },
142
+ { iso: "MM", name: "Myanmar", dialCode: "95", flag: "🇲🇲" },
143
+ { iso: "NA", name: "Namibia", dialCode: "264", flag: "🇳🇦" },
144
+ { iso: "NR", name: "Nauru", dialCode: "674", flag: "🇳🇷" },
145
+ { iso: "NP", name: "Nepal", dialCode: "977", flag: "🇳🇵" },
146
+ { iso: "NL", name: "Netherlands", dialCode: "31", flag: "🇳🇱" },
147
+ { iso: "NC", name: "New Caledonia", dialCode: "687", flag: "🇳🇨" },
148
+ { iso: "NZ", name: "New Zealand", dialCode: "64", flag: "🇳🇿" },
149
+ { iso: "NI", name: "Nicaragua", dialCode: "505", flag: "🇳🇮" },
150
+ { iso: "NE", name: "Niger", dialCode: "227", flag: "🇳🇪" },
151
+ { iso: "NG", name: "Nigeria", dialCode: "234", flag: "🇳🇬" },
152
+ { iso: "NU", name: "Niue", dialCode: "683", flag: "🇳🇺" },
153
+ { iso: "NF", name: "Norfolk Island", dialCode: "672", flag: "🇳🇫" },
154
+ { iso: "KP", name: "North Korea", dialCode: "850", flag: "🇰🇵" },
155
+ { iso: "MK", name: "North Macedonia", dialCode: "389", flag: "🇲🇰" },
156
+ { iso: "MP", name: "Northern Mariana Islands", dialCode: "1670", flag: "🇲🇵" },
157
+ { iso: "NO", name: "Norway", dialCode: "47", flag: "🇳🇴" },
158
+ { iso: "OM", name: "Oman", dialCode: "968", flag: "🇴🇲" },
159
+ { iso: "PK", name: "Pakistan", dialCode: "92", flag: "🇵🇰" },
160
+ { iso: "PW", name: "Palau", dialCode: "680", flag: "🇵🇼" },
161
+ { iso: "PS", name: "Palestine", dialCode: "970", flag: "🇵🇸" },
162
+ { iso: "PA", name: "Panama", dialCode: "507", flag: "🇵🇦" },
163
+ { iso: "PG", name: "Papua New Guinea", dialCode: "675", flag: "🇵🇬" },
164
+ { iso: "PY", name: "Paraguay", dialCode: "595", flag: "🇵🇾" },
165
+ { iso: "PE", name: "Peru", dialCode: "51", flag: "🇵🇪" },
166
+ { iso: "PH", name: "Philippines", dialCode: "63", flag: "🇵🇭" },
167
+ { iso: "PL", name: "Poland", dialCode: "48", flag: "🇵🇱" },
168
+ { iso: "PT", name: "Portugal", dialCode: "351", flag: "🇵🇹" },
169
+ { iso: "PR", name: "Puerto Rico", dialCode: "1787", flag: "🇵🇷" },
170
+ { iso: "QA", name: "Qatar", dialCode: "974", flag: "🇶🇦" },
171
+ { iso: "CG", name: "Republic of the Congo", dialCode: "242", flag: "🇨🇬" },
172
+ { iso: "RE", name: "Réunion", dialCode: "262", flag: "🇷🇪" },
173
+ { iso: "RO", name: "Romania", dialCode: "40", flag: "🇷🇴" },
174
+ { iso: "RU", name: "Russia", dialCode: "7", flag: "🇷🇺" },
175
+ { iso: "RW", name: "Rwanda", dialCode: "250", flag: "🇷🇼" },
176
+ { iso: "KN", name: "Saint Kitts and Nevis", dialCode: "1869", flag: "🇰🇳" },
177
+ { iso: "LC", name: "Saint Lucia", dialCode: "1758", flag: "🇱🇨" },
178
+ { iso: "PM", name: "Saint Pierre and Miquelon", dialCode: "508", flag: "🇵🇲" },
179
+ { iso: "VC", name: "Saint Vincent and the Grenadines", dialCode: "1784", flag: "🇻🇨" },
180
+ { iso: "WS", name: "Samoa", dialCode: "685", flag: "🇼🇸" },
181
+ { iso: "SM", name: "San Marino", dialCode: "378", flag: "🇸🇲" },
182
+ { iso: "ST", name: "São Tomé and Príncipe", dialCode: "239", flag: "🇸🇹" },
183
+ { iso: "SA", name: "Saudi Arabia", dialCode: "966", flag: "🇸🇦" },
184
+ { iso: "SN", name: "Senegal", dialCode: "221", flag: "🇸🇳" },
185
+ { iso: "RS", name: "Serbia", dialCode: "381", flag: "🇷🇸" },
186
+ { iso: "SC", name: "Seychelles", dialCode: "248", flag: "🇸🇨" },
187
+ { iso: "SL", name: "Sierra Leone", dialCode: "232", flag: "🇸🇱" },
188
+ { iso: "SG", name: "Singapore", dialCode: "65", flag: "🇸🇬" },
189
+ { iso: "SX", name: "Sint Maarten", dialCode: "1721", flag: "🇸🇽" },
190
+ { iso: "SK", name: "Slovakia", dialCode: "421", flag: "🇸🇰" },
191
+ { iso: "SI", name: "Slovenia", dialCode: "386", flag: "🇸🇮" },
192
+ { iso: "SB", name: "Solomon Islands", dialCode: "677", flag: "🇸🇧" },
193
+ { iso: "SO", name: "Somalia", dialCode: "252", flag: "🇸🇴" },
194
+ { iso: "ZA", name: "South Africa", dialCode: "27", flag: "🇿🇦" },
195
+ { iso: "KR", name: "South Korea", dialCode: "82", flag: "🇰🇷" },
196
+ { iso: "SS", name: "South Sudan", dialCode: "211", flag: "🇸🇸" },
197
+ { iso: "ES", name: "Spain", dialCode: "34", flag: "🇪🇸" },
198
+ { iso: "LK", name: "Sri Lanka", dialCode: "94", flag: "🇱🇰" },
199
+ { iso: "SD", name: "Sudan", dialCode: "249", flag: "🇸🇩" },
200
+ { iso: "SR", name: "Suriname", dialCode: "597", flag: "🇸🇷" },
201
+ { iso: "SE", name: "Sweden", dialCode: "46", flag: "🇸🇪" },
202
+ { iso: "CH", name: "Switzerland", dialCode: "41", flag: "🇨🇭" },
203
+ { iso: "SY", name: "Syria", dialCode: "963", flag: "🇸🇾" },
204
+ { iso: "TW", name: "Taiwan", dialCode: "886", flag: "🇹🇼" },
205
+ { iso: "TJ", name: "Tajikistan", dialCode: "992", flag: "🇹🇯" },
206
+ { iso: "TZ", name: "Tanzania", dialCode: "255", flag: "🇹🇿" },
207
+ { iso: "TH", name: "Thailand", dialCode: "66", flag: "🇹🇭" },
208
+ { iso: "TL", name: "Timor-Leste", dialCode: "670", flag: "🇹🇱" },
209
+ { iso: "TG", name: "Togo", dialCode: "228", flag: "🇹🇬" },
210
+ { iso: "TK", name: "Tokelau", dialCode: "690", flag: "🇹🇰" },
211
+ { iso: "TO", name: "Tonga", dialCode: "676", flag: "🇹🇴" },
212
+ { iso: "TT", name: "Trinidad and Tobago", dialCode: "1868", flag: "🇹🇹" },
213
+ { iso: "TN", name: "Tunisia", dialCode: "216", flag: "🇹🇳" },
214
+ { iso: "TR", name: "Turkey", dialCode: "90", flag: "🇹🇷" },
215
+ { iso: "TM", name: "Turkmenistan", dialCode: "993", flag: "🇹🇲" },
216
+ { iso: "TC", name: "Turks and Caicos Islands", dialCode: "1649", flag: "🇹🇨" },
217
+ { iso: "TV", name: "Tuvalu", dialCode: "688", flag: "🇹🇻" },
218
+ { iso: "VI", name: "U.S. Virgin Islands", dialCode: "1340", flag: "🇻🇮" },
219
+ { iso: "UG", name: "Uganda", dialCode: "256", flag: "🇺🇬" },
220
+ { iso: "UA", name: "Ukraine", dialCode: "380", flag: "🇺🇦" },
221
+ { iso: "AE", name: "United Arab Emirates", dialCode: "971", flag: "🇦🇪" },
222
+ { iso: "GB", name: "United Kingdom", dialCode: "44", flag: "🇬🇧" },
223
+ { iso: "US", name: "United States", dialCode: "1", flag: "🇺🇸" },
224
+ { iso: "UY", name: "Uruguay", dialCode: "598", flag: "🇺🇾" },
225
+ { iso: "UZ", name: "Uzbekistan", dialCode: "998", flag: "🇺🇿" },
226
+ { iso: "VU", name: "Vanuatu", dialCode: "678", flag: "🇻🇺" },
227
+ { iso: "VA", name: "Vatican City", dialCode: "379", flag: "🇻🇦" },
228
+ { iso: "VE", name: "Venezuela", dialCode: "58", flag: "🇻🇪" },
229
+ { iso: "VN", name: "Vietnam", dialCode: "84", flag: "🇻🇳" },
230
+ { iso: "WF", name: "Wallis and Futuna", dialCode: "681", flag: "🇼🇫" },
231
+ { iso: "YE", name: "Yemen", dialCode: "967", flag: "🇾🇪" },
232
+ { iso: "ZM", name: "Zambia", dialCode: "260", flag: "🇿🇲" },
233
+ { iso: "ZW", name: "Zimbabwe", dialCode: "263", flag: "🇿🇼" },
234
+ ];
235
+ /** Lookup map: ISO code -> Country */
236
+ export const ISO_MAP = new Map();
237
+ COUNTRIES.forEach((c) => ISO_MAP.set(c.iso, c));
238
+ /** Lookup map: dial code -> Country[]. Multiple countries can share a code (e.g. +1). */
239
+ export const DIAL_CODE_MAP = new Map();
240
+ COUNTRIES.forEach((c) => {
241
+ const arr = DIAL_CODE_MAP.get(c.dialCode) || [];
242
+ arr.push(c);
243
+ DIAL_CODE_MAP.set(c.dialCode, arr);
244
+ });
245
+ /**
246
+ * Sorted list of unique dial codes (longest first) for prefix detection during paste.
247
+ * Longest-first ensures we match "+1684" (American Samoa) before "+1" (US).
248
+ */
249
+ export const DIAL_CODES_DESC = [...new Set(COUNTRIES.map((c) => c.dialCode))].sort((a, b) => b.length - a.length);
@@ -66,6 +66,13 @@
66
66
  --stuic-input-localized-toggle-hover-bg: var(--stuic-color-muted);
67
67
  --stuic-input-localized-label-text: var(--stuic-color-muted-foreground);
68
68
 
69
+ /* FieldPhoneNumber specific */
70
+ --stuic-field-phone-trigger-text: var(--stuic-color-foreground);
71
+ --stuic-field-phone-trigger-text-hover: var(--stuic-color-foreground);
72
+ --stuic-field-phone-trigger-bg: transparent;
73
+ --stuic-field-phone-trigger-bg-hover: var(--stuic-color-muted);
74
+ --stuic-field-phone-trigger-border: var(--stuic-color-border);
75
+
69
76
  /* FieldOptions specific */
70
77
  --stuic-field-options-divider: var(--stuic-color-border);
71
78
  --stuic-field-options-control-text: var(--stuic-color-muted-foreground);
@@ -585,4 +592,39 @@
585
592
  border-right: 1px solid var(--stuic-input-localized-divider);
586
593
  /* border-radius: var(--stuic-input-radius); */
587
594
  }
595
+
596
+ /* ============================================================================
597
+ FIELD PHONE NUMBER
598
+ ============================================================================ */
599
+
600
+ .stuic-phone-prefix-trigger {
601
+ color: var(--stuic-field-phone-trigger-text);
602
+ background: var(--stuic-field-phone-trigger-bg);
603
+ border-right: 1px solid var(--stuic-field-phone-trigger-border);
604
+ border-radius: var(--stuic-input-radius) 0 0 var(--stuic-input-radius);
605
+ transition:
606
+ background var(--stuic-input-transition),
607
+ color var(--stuic-input-transition);
608
+ }
609
+
610
+ .stuic-phone-prefix-trigger:hover:not(:disabled) {
611
+ color: var(--stuic-field-phone-trigger-text-hover);
612
+ background: var(--stuic-field-phone-trigger-bg-hover);
613
+ }
614
+
615
+ .stuic-phone-prefix-trigger:disabled {
616
+ opacity: 0.5;
617
+ cursor: not-allowed;
618
+ }
619
+
620
+ .stuic-phone-prefix-picker {
621
+ display: flex;
622
+ align-items: stretch;
623
+ }
624
+
625
+ /* Reset field input styles leaking into the dropdown's search */
626
+ .stuic-phone-prefix-picker .stuic-dropdown-menu-search {
627
+ padding-top: 0;
628
+ padding-bottom: 0;
629
+ }
588
630
  }
@@ -13,3 +13,6 @@ export { default as FieldTextarea, type Props as FieldTextareaProps, } from "./F
13
13
  export { default as FieldInputLocalized, type Props as FieldInputLocalizedProps, } from "./FieldInputLocalized.svelte";
14
14
  export { default as FieldKeyValues, type Props as FieldKeyValuesProps, type KeyValueEntry, } from "./FieldKeyValues.svelte";
15
15
  export { default as FieldObject, type Props as FieldObjectProps, } from "./FieldObject.svelte";
16
+ export { default as FieldPhoneNumber, type Props as FieldPhoneNumberProps, } from "./FieldPhoneNumber.svelte";
17
+ export { validatePhoneNumber } from "./phone-validation.js";
18
+ export { type Country } from "./_internal/countries.js";
@@ -13,3 +13,6 @@ export { default as FieldTextarea, } from "./FieldTextarea.svelte";
13
13
  export { default as FieldInputLocalized, } from "./FieldInputLocalized.svelte";
14
14
  export { default as FieldKeyValues, } from "./FieldKeyValues.svelte";
15
15
  export { default as FieldObject, } from "./FieldObject.svelte";
16
+ export { default as FieldPhoneNumber, } from "./FieldPhoneNumber.svelte";
17
+ export { validatePhoneNumber } from "./phone-validation.js";
18
+ export {} from "./_internal/countries.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Built-in phone number validator for FieldPhoneNumber.
3
+ * Validates E.164 format (e.g. "+421905123456").
4
+ *
5
+ * Returns error message if invalid, undefined if valid or empty.
6
+ * Empty values are not validated — use the `required` attribute for that.
7
+ *
8
+ * Compatible with the `customValidator` signature of the validate action.
9
+ */
10
+ export declare function validatePhoneNumber(value: unknown, _context?: Record<string, unknown>, _el?: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement): string | undefined;
@@ -0,0 +1,22 @@
1
+ import { isValidPhoneNumber } from "libphonenumber-js/min";
2
+ /**
3
+ * Built-in phone number validator for FieldPhoneNumber.
4
+ * Validates E.164 format (e.g. "+421905123456").
5
+ *
6
+ * Returns error message if invalid, undefined if valid or empty.
7
+ * Empty values are not validated — use the `required` attribute for that.
8
+ *
9
+ * Compatible with the `customValidator` signature of the validate action.
10
+ */
11
+ export function validatePhoneNumber(value, _context, _el) {
12
+ if (!value || typeof value !== "string")
13
+ return undefined;
14
+ try {
15
+ if (isValidPhoneNumber(value))
16
+ return undefined;
17
+ return "Invalid phone number";
18
+ }
19
+ catch {
20
+ return "Invalid phone number";
21
+ }
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.23.3",
3
+ "version": "3.24.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",
@@ -29,7 +29,11 @@
29
29
  "types": "./dist/themes/*.d.ts",
30
30
  "default": "./dist/themes/*.js"
31
31
  },
32
- "./themes/css/*": "./dist/themes/css/*"
32
+ "./themes/css/*": "./dist/themes/css/*",
33
+ "./phone-validation": {
34
+ "types": "./dist/components/Input/phone-validation.d.ts",
35
+ "default": "./dist/components/Input/phone-validation.js"
36
+ }
33
37
  },
34
38
  "peerDependencies": {
35
39
  "svelte": "^5.0.0"
@@ -53,7 +57,7 @@
53
57
  "prettier": "^3.8.1",
54
58
  "prettier-plugin-svelte": "^3.4.1",
55
59
  "publint": "^0.3.17",
56
- "svelte": "^5.51.1",
60
+ "svelte": "^5.51.2",
57
61
  "svelte-check": "^4.4.0",
58
62
  "tailwindcss": "^4.1.18",
59
63
  "tsx": "^4.21.0",
@@ -69,8 +73,9 @@
69
73
  "@marianmeres/parse-boolean": "^2.0.5",
70
74
  "@marianmeres/ticker": "^1.16.5",
71
75
  "esm-env": "^1.2.2",
76
+ "libphonenumber-js": "^1.12.36",
72
77
  "runed": "^0.23.4",
73
- "tailwind-merge": "^3.4.0"
78
+ "tailwind-merge": "^3.4.1"
74
79
  },
75
80
  "scripts": {
76
81
  "dev": "vite dev",