fds-vue-core 8.2.3 → 8.2.4
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/components/FdsSearchSelect/FdsSearchSelect.vue.d.ts +3 -5
- package/dist/components/FdsSearchSelectPro/FdsSearchSelectPro.vue.d.ts +3 -5
- package/dist/components/Form/FdsInput/FdsInput.vue.d.ts +2 -3
- package/dist/components/Form/FdsInput/types.d.ts +0 -2
- package/dist/components/Form/FdsPhonenumber/FdsPhonenumber.vue.d.ts +0 -3
- package/dist/components/Form/FdsPhonenumber/normalizePhoneInput.d.ts +7 -0
- package/dist/components/Form/FdsPhonenumber/phoneMask.d.ts +2 -0
- package/dist/components/Form/FdsPhonenumber/types.d.ts +1 -1
- package/dist/fds-vue-core.cjs.js +162 -155
- package/dist/fds-vue-core.cjs.js.map +1 -1
- package/dist/fds-vue-core.css +1 -1
- package/dist/fds-vue-core.es.js +162 -155
- package/dist/fds-vue-core.es.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/components/Form/FdsInput/FdsInput.stories.ts +1 -7
- package/src/components/Form/FdsInput/FdsInput.vue +41 -163
- package/src/components/Form/FdsInput/types.ts +0 -2
- package/src/components/Form/FdsPhonenumber/FdsPhonenumber.vue +30 -9
- package/src/components/Form/FdsPhonenumber/normalizePhoneInput.ts +88 -0
- package/src/components/Form/FdsPhonenumber/phoneMask.ts +28 -0
- package/src/components/Form/FdsPhonenumber/types.ts +0 -3
package/package.json
CHANGED
|
@@ -61,13 +61,9 @@ const meta: Meta<typeof FdsInput> = {
|
|
|
61
61
|
control: { type: 'text' },
|
|
62
62
|
description: 'IMask pattern string (e.g., "00000000-0000" for personnummer)',
|
|
63
63
|
},
|
|
64
|
-
displayChar: {
|
|
65
|
-
control: { type: 'text' },
|
|
66
|
-
description: 'Character shown for unfilled mask positions while typing (e.g. "N" for postnummer)',
|
|
67
|
-
},
|
|
68
64
|
maskOptions: {
|
|
69
65
|
control: { type: 'object' },
|
|
70
|
-
description: 'Additional IMask options
|
|
66
|
+
description: 'Additional IMask options (lazy defaults to true).',
|
|
71
67
|
},
|
|
72
68
|
maxlength: {
|
|
73
69
|
control: { type: 'number' },
|
|
@@ -87,7 +83,6 @@ const meta: Meta<typeof FdsInput> = {
|
|
|
87
83
|
required: false,
|
|
88
84
|
type: 'text',
|
|
89
85
|
mask: undefined,
|
|
90
|
-
displayChar: undefined,
|
|
91
86
|
maskOptions: undefined,
|
|
92
87
|
maxlength: undefined,
|
|
93
88
|
},
|
|
@@ -320,7 +315,6 @@ export const WithMaskPostalCode: Story = {
|
|
|
320
315
|
v-model="postalCode"
|
|
321
316
|
label="Postnummer"
|
|
322
317
|
mask="000 00"
|
|
323
|
-
display-char="N"
|
|
324
318
|
/>
|
|
325
319
|
<p class="mt-4">Värde: {{ postalCode }}</p>
|
|
326
320
|
</div>
|
|
@@ -27,7 +27,6 @@ const props = withDefaults(defineProps<FdsInputProps>(), {
|
|
|
27
27
|
meta: undefined,
|
|
28
28
|
clearButton: false,
|
|
29
29
|
mask: undefined,
|
|
30
|
-
displayChar: undefined,
|
|
31
30
|
maskOptions: undefined,
|
|
32
31
|
class: undefined,
|
|
33
32
|
pattern: undefined,
|
|
@@ -39,7 +38,7 @@ const props = withDefaults(defineProps<FdsInputProps>(), {
|
|
|
39
38
|
|
|
40
39
|
const emit = defineEmits<{
|
|
41
40
|
(e: 'input', ev: Event): void
|
|
42
|
-
(e: '
|
|
41
|
+
(e: 'autocomplete', ev: Event): void
|
|
43
42
|
(e: 'clearInput'): void
|
|
44
43
|
(e: 'update:value', value: string): void
|
|
45
44
|
(e: 'keyup', ev: KeyboardEvent): void
|
|
@@ -143,108 +142,33 @@ const inputPaddingRight = computed(() => {
|
|
|
143
142
|
|
|
144
143
|
const externalClass = computed(() => (props.class ?? attrs.class) as HTMLAttributes['class'] | undefined)
|
|
145
144
|
|
|
146
|
-
const
|
|
147
|
-
const maskPlaceholderChar = ref('')
|
|
145
|
+
const inputPaddingClasses = 'px-3 py-[calc(0.75rem-1px)]'
|
|
148
146
|
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const lazy = props.maskOptions?.lazy ?? (props.displayChar ? false : true)
|
|
154
|
-
return !lazy
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
const maskFieldWrapperClasses = computed(() => {
|
|
158
|
-
if (!useMaskPlaceholderStyle.value) return undefined
|
|
159
|
-
return [
|
|
160
|
-
'rounded-md border border-gray-500 bg-white bg-clip-padding',
|
|
161
|
-
props.disabled && 'outline-dashed outline-2 outline-gray-400 cursor-not-allowed border-transparent',
|
|
162
|
-
isInvalid.value
|
|
163
|
-
? 'outline-2 -outline-offset-2 outline-red-600'
|
|
164
|
-
: 'focus-within:outline-2 focus-within:outline-blue-500 focus-within:outline-offset-0',
|
|
165
|
-
'focus-within:border-transparent',
|
|
166
|
-
]
|
|
167
|
-
})
|
|
168
|
-
|
|
169
|
-
const maskFieldPaddingClasses = 'px-3 py-[calc(0.75rem-1px)]'
|
|
170
|
-
|
|
171
|
-
const maskOverlayClasses = computed(() => [
|
|
172
|
-
'pointer-events-none absolute inset-0 z-[3] flex items-center',
|
|
173
|
-
maskFieldPaddingClasses,
|
|
147
|
+
const inputClasses = computed(() => [
|
|
148
|
+
'block rounded-md border border-gray-500',
|
|
149
|
+
maxlength.value ? '' : 'w-full',
|
|
150
|
+
inputPaddingClasses,
|
|
174
151
|
inputPaddingRight.value,
|
|
152
|
+
'focus:outline-2 focus:outline-blue-500 -outline-offset-2 focus:border-transparent',
|
|
153
|
+
showDateIcon.value &&
|
|
154
|
+
'[&::-webkit-calendar-picker-indicator]:opacity-0 [&::-webkit-calendar-picker-indicator]:pointer-events-none',
|
|
155
|
+
props.disabled
|
|
156
|
+
? 'text-gray-800 outline-dashed outline-2 outline-gray-400 cursor-not-allowed border-transparent'
|
|
157
|
+
: 'text-gray-900 bg-white',
|
|
158
|
+
isInvalid.value && 'outline-2 outline-red-600',
|
|
159
|
+
props.inputClass,
|
|
175
160
|
])
|
|
176
161
|
|
|
177
|
-
const maskDisplayChars = computed(() => {
|
|
178
|
-
const display = maskDisplayValue.value
|
|
179
|
-
const placeholder = maskPlaceholderChar.value || resolvedPlaceholderChar.value
|
|
180
|
-
if (!display || !placeholder) return []
|
|
181
|
-
return [...display].map((char) => ({
|
|
182
|
-
char,
|
|
183
|
-
isPlaceholder: char === placeholder,
|
|
184
|
-
}))
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
function maskCharStyle(isPlaceholder: boolean): { color: string } {
|
|
188
|
-
if (isPlaceholder) {
|
|
189
|
-
return { color: props.disabled ? 'var(--color-gray-500)' : 'var(--color-gray-400)' }
|
|
190
|
-
}
|
|
191
|
-
return { color: props.disabled ? 'var(--color-gray-800)' : 'var(--color-gray-900)' }
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const inputClasses = computed(() => {
|
|
195
|
-
const shared = [
|
|
196
|
-
'block rounded-md',
|
|
197
|
-
maxlength.value ? '' : 'w-full',
|
|
198
|
-
inputPaddingRight.value,
|
|
199
|
-
showDateIcon.value &&
|
|
200
|
-
'[&::-webkit-calendar-picker-indicator]:opacity-0 [&::-webkit-calendar-picker-indicator]:pointer-events-none',
|
|
201
|
-
props.inputClass,
|
|
202
|
-
]
|
|
203
|
-
|
|
204
|
-
if (useMaskPlaceholderStyle.value) {
|
|
205
|
-
return [
|
|
206
|
-
...shared,
|
|
207
|
-
'relative z-[2] border-0 bg-transparent shadow-none focus:outline-none',
|
|
208
|
-
maskFieldPaddingClasses,
|
|
209
|
-
'text-base leading-6 tabular-nums',
|
|
210
|
-
props.disabled ? 'cursor-not-allowed' : '',
|
|
211
|
-
]
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return [
|
|
215
|
-
...shared,
|
|
216
|
-
'rounded-md border border-gray-500',
|
|
217
|
-
maskFieldPaddingClasses,
|
|
218
|
-
'focus:outline-2 focus:outline-blue-500 -outline-offset-2 focus:border-transparent',
|
|
219
|
-
props.disabled
|
|
220
|
-
? 'text-gray-800 outline-dashed outline-2 outline-gray-400 cursor-not-allowed border-transparent'
|
|
221
|
-
: 'text-gray-900 bg-white',
|
|
222
|
-
isInvalid.value && 'outline-2 outline-red-600',
|
|
223
|
-
]
|
|
224
|
-
})
|
|
225
|
-
|
|
226
162
|
const inputStyle = computed(() => {
|
|
227
|
-
|
|
228
|
-
? {
|
|
229
|
-
width: `calc(${maxlength.value}ch + 1.5rem + 0.25rem)`,
|
|
230
|
-
maxWidth: '100%',
|
|
231
|
-
}
|
|
232
|
-
: {}
|
|
233
|
-
|
|
234
|
-
if (!useMaskPlaceholderStyle.value) {
|
|
235
|
-
return size
|
|
236
|
-
}
|
|
237
|
-
|
|
163
|
+
if (!maxlength.value) return undefined
|
|
238
164
|
return {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
WebkitTextFillColor: 'transparent',
|
|
242
|
-
caretColor: props.disabled ? '#1f2937' : '#111827',
|
|
165
|
+
width: `calc(${maxlength.value}ch + 1.5rem + 0.25rem)`,
|
|
166
|
+
maxWidth: '100%',
|
|
243
167
|
}
|
|
244
168
|
})
|
|
245
169
|
|
|
246
170
|
const rightIconsContainerClasses = computed(() => [
|
|
247
|
-
'absolute
|
|
171
|
+
'absolute flex gap-2 top-1/2 -translate-y-1/2 items-center justify-end',
|
|
248
172
|
showClearButton.value ? 'right-2' : 'right-3',
|
|
249
173
|
])
|
|
250
174
|
|
|
@@ -268,7 +192,6 @@ function onClear() {
|
|
|
268
192
|
internalValue.value = ''
|
|
269
193
|
if (maskInstance) {
|
|
270
194
|
maskInstance.value = ''
|
|
271
|
-
nextTick(syncMaskDisplay)
|
|
272
195
|
}
|
|
273
196
|
emit('clearInput')
|
|
274
197
|
}
|
|
@@ -310,74 +233,28 @@ const inputRef = ref<HTMLInputElement | null>(null)
|
|
|
310
233
|
|
|
311
234
|
let maskInstance: any = null
|
|
312
235
|
|
|
313
|
-
function getMaskEmittedValue(instance: typeof maskInstance): string {
|
|
314
|
-
if (!instance) return ''
|
|
315
|
-
|
|
316
|
-
const raw = String(instance.value ?? '')
|
|
317
|
-
if (!useMaskPlaceholderStyle.value) return raw
|
|
318
|
-
|
|
319
|
-
const placeholder = instance.masked?.placeholderChar ?? resolvedPlaceholderChar.value
|
|
320
|
-
if (!placeholder) return raw
|
|
321
|
-
|
|
322
|
-
return raw.split(placeholder).join('').replace(/\s+$/, '')
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function syncMaskDisplay() {
|
|
326
|
-
if (!inputRef.value) {
|
|
327
|
-
maskDisplayValue.value = ''
|
|
328
|
-
maskPlaceholderChar.value = ''
|
|
329
|
-
return
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
maskPlaceholderChar.value = maskInstance?.masked?.placeholderChar ?? resolvedPlaceholderChar.value ?? ''
|
|
333
|
-
maskDisplayValue.value = maskInstance?.displayValue ?? inputRef.value.value ?? maskInstance?.value ?? ''
|
|
334
|
-
}
|
|
335
|
-
|
|
336
236
|
const createMask = () => {
|
|
337
|
-
// Destroy existing mask if any
|
|
338
237
|
if (maskInstance) {
|
|
339
238
|
maskInstance.destroy()
|
|
340
239
|
maskInstance = null
|
|
341
240
|
}
|
|
342
241
|
|
|
343
|
-
// Create new mask if mask prop is provided and input exists
|
|
344
242
|
if (props.mask && inputRef.value) {
|
|
345
|
-
|
|
243
|
+
maskInstance = IMask(inputRef.value, {
|
|
346
244
|
mask: props.mask,
|
|
347
245
|
...props.maskOptions,
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
placeholderChar: props.displayChar,
|
|
351
|
-
lazy: props.maskOptions?.lazy ?? false,
|
|
352
|
-
}
|
|
353
|
-
: {}),
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
maskInstance = IMask(inputRef.value, maskConfig as any)
|
|
246
|
+
lazy: props.maskOptions?.lazy ?? true,
|
|
247
|
+
} as any)
|
|
357
248
|
|
|
358
|
-
// Sync mask value with internalValue
|
|
359
249
|
if (internalValue.value) {
|
|
360
250
|
maskInstance.value = internalValue.value
|
|
361
251
|
}
|
|
362
252
|
|
|
363
253
|
maskInstance.on('accept', () => {
|
|
364
254
|
if (maskInstance) {
|
|
365
|
-
internalValue.value =
|
|
366
|
-
nextTick(syncMaskDisplay)
|
|
255
|
+
internalValue.value = maskInstance.value
|
|
367
256
|
}
|
|
368
257
|
})
|
|
369
|
-
|
|
370
|
-
maskInstance.on('complete', () => {
|
|
371
|
-
nextTick(syncMaskDisplay)
|
|
372
|
-
})
|
|
373
|
-
|
|
374
|
-
nextTick(() => {
|
|
375
|
-
syncMaskDisplay()
|
|
376
|
-
nextTick(syncMaskDisplay)
|
|
377
|
-
})
|
|
378
|
-
} else {
|
|
379
|
-
maskDisplayValue.value = ''
|
|
380
|
-
maskPlaceholderChar.value = ''
|
|
381
258
|
}
|
|
382
259
|
}
|
|
383
260
|
|
|
@@ -412,7 +289,7 @@ const ariaDescribedBy = computed(() => {
|
|
|
412
289
|
|
|
413
290
|
// Watch for mask-related prop changes and recreate mask
|
|
414
291
|
watch(
|
|
415
|
-
() => [props.mask, props.
|
|
292
|
+
() => [props.mask, props.maskOptions] as const,
|
|
416
293
|
() => {
|
|
417
294
|
nextTick(() => {
|
|
418
295
|
createMask()
|
|
@@ -421,16 +298,25 @@ watch(
|
|
|
421
298
|
{ deep: true },
|
|
422
299
|
)
|
|
423
300
|
|
|
301
|
+
function shouldSyncMaskFromModel(newValue: string | undefined, currentMaskedValue: string): boolean {
|
|
302
|
+
if (newValue === undefined) {
|
|
303
|
+
return false
|
|
304
|
+
}
|
|
305
|
+
if (document.activeElement !== inputRef.value) {
|
|
306
|
+
return true
|
|
307
|
+
}
|
|
308
|
+
const newDigits = String(newValue).replace(/\D/g, '')
|
|
309
|
+
const currentDigits = String(currentMaskedValue ?? '').replace(/\D/g, '')
|
|
310
|
+
return newDigits !== currentDigits
|
|
311
|
+
}
|
|
312
|
+
|
|
424
313
|
// Watch for external value changes and update mask
|
|
425
|
-
// Only update if the value actually changed and user is not actively editing
|
|
426
314
|
watch(
|
|
427
315
|
() => props.value,
|
|
428
316
|
(newValue) => {
|
|
429
|
-
if (maskInstance && newValue !== undefined && newValue !==
|
|
430
|
-
|
|
431
|
-
if (document.activeElement !== inputRef.value) {
|
|
317
|
+
if (maskInstance && newValue !== undefined && newValue !== maskInstance.value) {
|
|
318
|
+
if (shouldSyncMaskFromModel(newValue, maskInstance.value)) {
|
|
432
319
|
maskInstance.value = newValue
|
|
433
|
-
nextTick(syncMaskDisplay)
|
|
434
320
|
}
|
|
435
321
|
}
|
|
436
322
|
},
|
|
@@ -439,11 +325,9 @@ watch(
|
|
|
439
325
|
watch(
|
|
440
326
|
() => modelValue.value,
|
|
441
327
|
(newValue) => {
|
|
442
|
-
if (maskInstance && newValue !== undefined && newValue !==
|
|
443
|
-
|
|
444
|
-
if (document.activeElement !== inputRef.value) {
|
|
328
|
+
if (maskInstance && newValue !== undefined && newValue !== maskInstance.value) {
|
|
329
|
+
if (shouldSyncMaskFromModel(newValue, maskInstance.value)) {
|
|
445
330
|
maskInstance.value = newValue
|
|
446
|
-
nextTick(syncMaskDisplay)
|
|
447
331
|
}
|
|
448
332
|
}
|
|
449
333
|
},
|
|
@@ -469,7 +353,7 @@ watch(
|
|
|
469
353
|
</FdsMeta>
|
|
470
354
|
</div>
|
|
471
355
|
<div :class="{ 'flex-1': props.labelLeft }">
|
|
472
|
-
<div class="relative"
|
|
356
|
+
<div class="relative">
|
|
473
357
|
<input
|
|
474
358
|
ref="inputRef"
|
|
475
359
|
:type="inputType === 'password' ? (showPassword ? 'text' : 'password') : inputType"
|
|
@@ -489,6 +373,7 @@ watch(
|
|
|
489
373
|
v-bind="inputBindings"
|
|
490
374
|
@input="handleInputChange"
|
|
491
375
|
@change="handleInputChange"
|
|
376
|
+
@autocomplete="$emit('autocomplete', $event)"
|
|
492
377
|
@blur="$emit('blur', $event)"
|
|
493
378
|
@keyup="
|
|
494
379
|
(ev) => {
|
|
@@ -497,13 +382,6 @@ watch(
|
|
|
497
382
|
}
|
|
498
383
|
"
|
|
499
384
|
/>
|
|
500
|
-
<div v-if="useMaskPlaceholderStyle" :class="maskOverlayClasses" aria-hidden="true">
|
|
501
|
-
<span class="whitespace-pre text-base leading-6 tabular-nums">
|
|
502
|
-
<span v-for="(item, index) in maskDisplayChars" :key="index" :style="maskCharStyle(item.isPlaceholder)">
|
|
503
|
-
{{ item.char }}
|
|
504
|
-
</span>
|
|
505
|
-
</span>
|
|
506
|
-
</div>
|
|
507
385
|
<div :class="rightIconsContainerClasses">
|
|
508
386
|
<FdsButtonIcon
|
|
509
387
|
v-if="showDateIcon"
|
|
@@ -9,8 +9,6 @@ export interface FdsInputProps {
|
|
|
9
9
|
labelLeft?: boolean
|
|
10
10
|
clearButton?: boolean
|
|
11
11
|
mask?: string | RegExp | Array<string | RegExp>
|
|
12
|
-
/** Character shown for unfilled mask positions while typing (maps to IMask placeholderChar). */
|
|
13
|
-
displayChar?: string
|
|
14
12
|
modelValue?: string
|
|
15
13
|
value?: string
|
|
16
14
|
type?: string
|
|
@@ -3,6 +3,8 @@ import { computed, ref, useAttrs, watch } from 'vue'
|
|
|
3
3
|
import { useFdsI18n } from '../../../plugin/useFdsI18n'
|
|
4
4
|
import FdsInput from '../FdsInput/FdsInput.vue'
|
|
5
5
|
import { buildCountryOptions, sortCountryOptionsByName } from './countries'
|
|
6
|
+
import { getPhoneMaskForCountry } from './phoneMask'
|
|
7
|
+
import { normalizeNationalPhoneInput, shouldNormalizePhoneInput } from './normalizePhoneInput'
|
|
6
8
|
import FdsPhonenumberCountryPicker from './FdsPhonenumberCountryPicker.vue'
|
|
7
9
|
import type { FdsPhonenumberEmits, FdsPhonenumberProps } from './types'
|
|
8
10
|
import { getPhoneValidationState, validatePhoneNumber } from './validatePhone'
|
|
@@ -11,7 +13,7 @@ defineOptions({
|
|
|
11
13
|
inheritAttrs: false,
|
|
12
14
|
})
|
|
13
15
|
|
|
14
|
-
const [nationalNumber
|
|
16
|
+
const [nationalNumber] = defineModel<string>({ default: '' })
|
|
15
17
|
const country = defineModel<string>('country', { default: 'SE' })
|
|
16
18
|
const attrs = useAttrs()
|
|
17
19
|
|
|
@@ -25,12 +27,9 @@ const props = withDefaults(defineProps<FdsPhonenumberProps>(), {
|
|
|
25
27
|
autocomplete: 'tel',
|
|
26
28
|
required: false,
|
|
27
29
|
placeholder: undefined,
|
|
28
|
-
maxlength: undefined,
|
|
29
|
-
minlength: undefined,
|
|
30
30
|
name: undefined,
|
|
31
31
|
autofocus: false,
|
|
32
32
|
readonly: false,
|
|
33
|
-
pattern: undefined,
|
|
34
33
|
defaultCountry: 'SE',
|
|
35
34
|
countries: undefined,
|
|
36
35
|
locale: undefined,
|
|
@@ -57,12 +56,10 @@ const forwardedInputProps = computed(() => ({
|
|
|
57
56
|
autocomplete: props.autocomplete,
|
|
58
57
|
required: props.required,
|
|
59
58
|
placeholder: props.placeholder,
|
|
60
|
-
maxlength: props.maxlength,
|
|
61
|
-
minlength: props.minlength,
|
|
62
59
|
name: props.name,
|
|
63
60
|
autofocus: props.autofocus,
|
|
64
61
|
readonly: props.readonly,
|
|
65
|
-
|
|
62
|
+
inputmode: 'tel',
|
|
66
63
|
...inputAttrs.value,
|
|
67
64
|
}))
|
|
68
65
|
|
|
@@ -77,6 +74,12 @@ const countryItems = computed(() => {
|
|
|
77
74
|
)
|
|
78
75
|
})
|
|
79
76
|
|
|
77
|
+
const phoneMask = computed(() => getPhoneMaskForCountry(country.value ?? props.defaultCountry))
|
|
78
|
+
|
|
79
|
+
const phoneMaskOptions = computed(() => ({
|
|
80
|
+
prepare: (value: string) => applyPhoneNormalization(value, country.value ?? props.defaultCountry),
|
|
81
|
+
}))
|
|
82
|
+
|
|
80
83
|
/** Set after the phone number field has blurred; validation does not run on input. */
|
|
81
84
|
const committedValid = ref<boolean | null>(null)
|
|
82
85
|
|
|
@@ -109,6 +112,20 @@ function runValidation() {
|
|
|
109
112
|
emit('update:e164', result.isValid && result.phoneNumber ? result.phoneNumber : '')
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
function applyPhoneNormalization(value: string, countryCode = country.value ?? props.defaultCountry): string {
|
|
116
|
+
if (!shouldNormalizePhoneInput(value, countryCode)) {
|
|
117
|
+
return value
|
|
118
|
+
}
|
|
119
|
+
return normalizeNationalPhoneInput(value, countryCode)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
watch(nationalNumber, (value) => {
|
|
123
|
+
const normalized = applyPhoneNormalization(value ?? '')
|
|
124
|
+
if (normalized !== (value ?? '')) {
|
|
125
|
+
nationalNumber.value = normalized
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
112
129
|
watch(country, () => {
|
|
113
130
|
if (committedValid.value === null) {
|
|
114
131
|
return
|
|
@@ -117,6 +134,10 @@ watch(country, () => {
|
|
|
117
134
|
})
|
|
118
135
|
|
|
119
136
|
function handleBlur(ev: FocusEvent) {
|
|
137
|
+
const normalized = applyPhoneNormalization(nationalNumber.value ?? '')
|
|
138
|
+
if (normalized !== (nationalNumber.value ?? '')) {
|
|
139
|
+
nationalNumber.value = normalized
|
|
140
|
+
}
|
|
120
141
|
runValidation()
|
|
121
142
|
emit('blur', ev)
|
|
122
143
|
}
|
|
@@ -152,8 +173,8 @@ function onNoCountryResults(value: boolean) {
|
|
|
152
173
|
/>
|
|
153
174
|
<FdsInput
|
|
154
175
|
v-model="nationalNumber"
|
|
155
|
-
:
|
|
156
|
-
|
|
176
|
+
:mask="phoneMask"
|
|
177
|
+
:mask-options="phoneMaskOptions"
|
|
157
178
|
:valid="displayValid"
|
|
158
179
|
:disabled="disabled"
|
|
159
180
|
:optional="optional"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import parsePhoneNumberFromString, { getCountryCallingCode, type CountryCode } from 'libphonenumber-js/mobile/es6'
|
|
2
|
+
import { getPhoneMaskForCountry } from './phoneMask'
|
|
3
|
+
|
|
4
|
+
const nationalDigitsForMask = (parsed: NonNullable<ReturnType<typeof parsePhoneNumberFromString>>): string =>
|
|
5
|
+
parsed.formatNational().replace(/\D/g, '')
|
|
6
|
+
|
|
7
|
+
const tryParseForCountry = (candidate: string, country: CountryCode): ReturnType<typeof parsePhoneNumberFromString> => {
|
|
8
|
+
const parsed = parsePhoneNumberFromString(candidate, country) ?? parsePhoneNumberFromString(candidate)
|
|
9
|
+
if (!parsed?.isValid() || parsed.country !== country) {
|
|
10
|
+
return undefined
|
|
11
|
+
}
|
|
12
|
+
return parsed
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Whether the value likely contains a country calling code or international formatting. */
|
|
16
|
+
export const shouldNormalizePhoneInput = (value: string, countryIso2: string): boolean => {
|
|
17
|
+
const trimmed = value.trim()
|
|
18
|
+
if (!trimmed) {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
if (trimmed.includes('+') || trimmed.startsWith('00')) {
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const digits = trimmed.replace(/\D/g, '')
|
|
26
|
+
const mask = getPhoneMaskForCountry(countryIso2)
|
|
27
|
+
if (!mask) {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const maxDigits = (mask.match(/0/g) ?? []).length
|
|
32
|
+
if (digits.length > maxDigits) {
|
|
33
|
+
return true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const callingCode = getCountryCallingCode(countryIso2 as CountryCode)
|
|
38
|
+
if (digits.startsWith(callingCode) && digits.length > maxDigits - 1) {
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Converts international/autocomplete values to national digits for the IMask field.
|
|
50
|
+
* IMask reapplies spacing from the country mask.
|
|
51
|
+
*/
|
|
52
|
+
export const normalizeNationalPhoneInput = (value: string, countryIso2: string): string => {
|
|
53
|
+
const trimmed = value.trim()
|
|
54
|
+
if (!trimmed) {
|
|
55
|
+
return ''
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const country = countryIso2.trim().toUpperCase() as CountryCode
|
|
59
|
+
const digits = trimmed.replace(/\D/g, '')
|
|
60
|
+
|
|
61
|
+
const candidates = new Set<string>([trimmed])
|
|
62
|
+
if (digits) {
|
|
63
|
+
candidates.add(digits)
|
|
64
|
+
candidates.add(`+${digits}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
const parsed = tryParseForCountry(candidate, country)
|
|
69
|
+
if (parsed) {
|
|
70
|
+
return nationalDigitsForMask(parsed)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const callingCode = getCountryCallingCode(country)
|
|
76
|
+
if (digits.startsWith(callingCode) && digits.length > callingCode.length) {
|
|
77
|
+
const withoutCode = digits.slice(callingCode.length)
|
|
78
|
+
const parsed = tryParseForCountry(withoutCode, country) ?? tryParseForCountry(`0${withoutCode}`, country)
|
|
79
|
+
if (parsed) {
|
|
80
|
+
return nationalDigitsForMask(parsed)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
return trimmed
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return trimmed
|
|
88
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getExampleNumber, type CountryCode } from 'libphonenumber-js/mobile/es6'
|
|
2
|
+
import examples from 'libphonenumber-js/mobile/examples'
|
|
3
|
+
|
|
4
|
+
const maskCache = new Map<string, string>()
|
|
5
|
+
|
|
6
|
+
/** IMask pattern with space grouping derived from libphonenumber mobile example numbers. */
|
|
7
|
+
export const getPhoneMaskForCountry = (countryIso2: string): string | undefined => {
|
|
8
|
+
const country = countryIso2.trim().toUpperCase() as CountryCode
|
|
9
|
+
const cached = maskCache.get(country)
|
|
10
|
+
if (cached) {
|
|
11
|
+
return cached
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const example = getExampleNumber(country, examples)
|
|
15
|
+
if (!example) {
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const groups = example
|
|
20
|
+
.formatNational()
|
|
21
|
+
.split(/\D+/)
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((part) => part.length)
|
|
24
|
+
|
|
25
|
+
const mask = groups.map((size) => '0'.repeat(size)).join(' ')
|
|
26
|
+
maskCache.set(country, mask)
|
|
27
|
+
return mask
|
|
28
|
+
}
|
|
@@ -7,12 +7,9 @@ type FdsPhonenumberInputPassthroughProps = Pick<
|
|
|
7
7
|
| 'autocomplete'
|
|
8
8
|
| 'required'
|
|
9
9
|
| 'placeholder'
|
|
10
|
-
| 'maxlength'
|
|
11
|
-
| 'minlength'
|
|
12
10
|
| 'name'
|
|
13
11
|
| 'autofocus'
|
|
14
12
|
| 'readonly'
|
|
15
|
-
| 'pattern'
|
|
16
13
|
>
|
|
17
14
|
|
|
18
15
|
export interface FdsPhonenumberProps extends FdsPhonenumberInputPassthroughProps {
|