fds-vue-core 7.1.9 → 7.2.2
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/components.d.ts +2 -0
- package/dist/components/Form/FdsPhonenumber/FdsPhonenumber.stories.d.ts +10 -0
- package/dist/components/Form/FdsPhonenumber/FdsPhonenumber.vue.d.ts +36 -0
- package/dist/components/Form/FdsPhonenumber/FdsPhonenumberCountryPicker.vue.d.ts +30 -0
- package/dist/components/Form/FdsPhonenumber/countries.d.ts +14 -0
- package/dist/components/Form/FdsPhonenumber/types.d.ts +36 -0
- package/dist/components/Form/FdsPhonenumber/validatePhone.d.ts +16 -0
- package/dist/composables/useIsPid.d.ts +11 -0
- package/dist/fds-vue-core.cjs.js +3396 -613
- 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 +3397 -614
- package/dist/fds-vue-core.es.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +19 -18
- package/src/components/FdsSearchSelect/FdsSearchSelect.vue +18 -31
- package/src/components/FdsSearchSelectPro/FdsSearchSelectPro.vue +14 -30
- package/src/components/FdsSearchSelectPro/useSearchSelectProItems.ts +10 -4
- package/src/components/FdsWizard/FdsWizard.vue +1 -1
- package/src/components/Form/FdsPhonenumber/FdsPhonenumber.stories.ts +156 -0
- package/src/components/Form/FdsPhonenumber/FdsPhonenumber.vue +138 -0
- package/src/components/Form/FdsPhonenumber/FdsPhonenumberCountryPicker.vue +426 -0
- package/src/components/Form/FdsPhonenumber/countries.ts +111 -0
- package/src/components/Form/FdsPhonenumber/types.ts +39 -0
- package/src/components/Form/FdsPhonenumber/validatePhone.ts +107 -0
- package/src/composables/useIsPid.ts +49 -11
- package/src/index.ts +36 -1
- package/src/lang/en.json +4 -0
- package/src/lang/sv.json +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fds-vue-core",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.2.2",
|
|
4
4
|
"description": "FDS Vue Core Component Library",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/fds-vue-core.cjs.js",
|
|
@@ -48,30 +48,31 @@
|
|
|
48
48
|
"vue": "^3.5.25"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"axios": "1.16.
|
|
52
|
-
"date-fns": "4.1
|
|
51
|
+
"axios": "1.16.1",
|
|
52
|
+
"date-fns": "4.2.1",
|
|
53
53
|
"imask": "7.6.1",
|
|
54
|
-
"
|
|
55
|
-
"
|
|
54
|
+
"libphonenumber-js": "1.12.36",
|
|
55
|
+
"tailwindcss": "4.3.0",
|
|
56
|
+
"vue-i18n": "11.4.4"
|
|
56
57
|
},
|
|
57
58
|
"devDependencies": {
|
|
58
|
-
"@
|
|
59
|
-
"@
|
|
60
|
-
"@storybook/addon-a11y": "10.
|
|
61
|
-
"@storybook/addon-docs": "10.
|
|
62
|
-
"@storybook/addon-vitest": "10.
|
|
63
|
-
"@storybook/vue3": "10.
|
|
64
|
-
"@storybook/vue3-vite": "10.
|
|
65
|
-
"@tailwindcss/vite": "4.
|
|
59
|
+
"@chromatic-com/storybook": "5.2.1",
|
|
60
|
+
"@intlify/unplugin-vue-i18n": "11.2.3",
|
|
61
|
+
"@storybook/addon-a11y": "10.4.0",
|
|
62
|
+
"@storybook/addon-docs": "10.4.0",
|
|
63
|
+
"@storybook/addon-vitest": "10.4.0",
|
|
64
|
+
"@storybook/vue3": "10.4.0",
|
|
65
|
+
"@storybook/vue3-vite": "10.4.0",
|
|
66
|
+
"@tailwindcss/vite": "4.3.0",
|
|
66
67
|
"@types/node": "22.16.5",
|
|
67
|
-
"@vitejs/plugin-vue": "6.0.
|
|
68
|
+
"@vitejs/plugin-vue": "6.0.7",
|
|
68
69
|
"@vitest/browser": "3.2.4",
|
|
69
|
-
"fg-devkit": "1.8.
|
|
70
|
-
"storybook": "10.
|
|
71
|
-
"tsx": "4.
|
|
70
|
+
"fg-devkit": "1.8.5",
|
|
71
|
+
"storybook": "10.4.0",
|
|
72
|
+
"tsx": "4.22.3",
|
|
72
73
|
"vite": "7.3.2",
|
|
73
74
|
"vite-plugin-dts": "4.5.4",
|
|
74
|
-
"vite-plugin-vue-devtools": "8.1.
|
|
75
|
+
"vite-plugin-vue-devtools": "8.1.2",
|
|
75
76
|
"vitest": "3.2.4",
|
|
76
77
|
"vue": "3.5.34"
|
|
77
78
|
},
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
3
3
|
import { useBoldQuery } from '../../composables/useBoldQuery'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
formatPidWithDash,
|
|
6
|
+
normalizePidSearchValue,
|
|
7
|
+
PID_MASK,
|
|
8
|
+
PID_MASK_OPTIONS,
|
|
9
|
+
useIsPid,
|
|
10
|
+
} from '../../composables/useIsPid'
|
|
5
11
|
import FdsPagination from '../FdsPagination/FdsPagination.vue'
|
|
6
12
|
import FdsSpinner from '../FdsSpinner/FdsSpinner.vue'
|
|
7
13
|
import FdsInput from '../Form/FdsInput/FdsInput.vue'
|
|
@@ -179,7 +185,7 @@ const matchesSearchTerm = (item: Record<string, unknown>): boolean => {
|
|
|
179
185
|
|
|
180
186
|
// When mask is active, use unmasked value (digits only) for searching
|
|
181
187
|
// This ensures we can search even when user has partially typed a PID
|
|
182
|
-
const searchValue = isPid.value ? searchTerm.value
|
|
188
|
+
const searchValue = isPid.value ? normalizePidSearchValue(searchTerm.value) : searchTerm.value
|
|
183
189
|
|
|
184
190
|
const searchLower = searchValue.toLowerCase()
|
|
185
191
|
const pidRegex = /^\d{8}[a-zA-Z0-9]{4}$/
|
|
@@ -195,10 +201,13 @@ const matchesSearchTerm = (item: Record<string, unknown>): boolean => {
|
|
|
195
201
|
|
|
196
202
|
const stringValue = String(value)
|
|
197
203
|
const valueLower = stringValue.toLowerCase()
|
|
198
|
-
const unmaskedValue = stringValue.replace(/\D/g, '')
|
|
204
|
+
const unmaskedValue = isPid.value ? normalizePidSearchValue(stringValue) : stringValue.replace(/\D/g, '')
|
|
199
205
|
|
|
200
206
|
// Check both masked and unmasked versions
|
|
201
|
-
return
|
|
207
|
+
return (
|
|
208
|
+
valueLower.includes(searchLower) ||
|
|
209
|
+
(unmaskedValue.length > 0 && unmaskedValue.toLowerCase().includes(searchLower))
|
|
210
|
+
)
|
|
202
211
|
})
|
|
203
212
|
}
|
|
204
213
|
|
|
@@ -249,25 +258,6 @@ watch(
|
|
|
249
258
|
{ immediate: true },
|
|
250
259
|
)
|
|
251
260
|
|
|
252
|
-
const formatPidWithDash = (value: string): string => {
|
|
253
|
-
// Only format if it looks like a personnummer
|
|
254
|
-
if (!isPidString(value)) {
|
|
255
|
-
// If it's not a PID, remove any trailing dash that might be left
|
|
256
|
-
return value.replace(/-$/, '')
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Remove all non-digits
|
|
260
|
-
const digits = value.replace(/\D/g, '')
|
|
261
|
-
|
|
262
|
-
// Only add dash if there are digits after the first 8
|
|
263
|
-
if (digits.length > 8) {
|
|
264
|
-
return `${digits.substring(0, 8)}-${digits.substring(8)}`
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// If exactly 8 digits or less, return without dash
|
|
268
|
-
return digits
|
|
269
|
-
}
|
|
270
|
-
|
|
271
261
|
const debounce = <T extends (...args: any[]) => void>(fn: T, delay: number) => {
|
|
272
262
|
let timeout: ReturnType<typeof setTimeout>
|
|
273
263
|
return (...args: Parameters<T>) => {
|
|
@@ -335,12 +325,9 @@ const { isPid } = useIsPid(searchTerm)
|
|
|
335
325
|
const { boldQuery } = useBoldQuery(searchTerm)
|
|
336
326
|
|
|
337
327
|
// Mask configuration for personnummer (yyyymmdd-nnnn)
|
|
338
|
-
const pidMask = computed(() =>
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
return undefined
|
|
343
|
-
})
|
|
328
|
+
const pidMask = computed(() => (isPid.value ? PID_MASK : undefined))
|
|
329
|
+
|
|
330
|
+
const pidMaskOptions = computed(() => (isPid.value ? PID_MASK_OPTIONS : { lazy: true }))
|
|
344
331
|
|
|
345
332
|
const handleMatchingString = (item: Record<string, unknown>): string => {
|
|
346
333
|
const values = searchFields.value.map((key) => String(item[key] || ''))
|
|
@@ -350,7 +337,7 @@ const handleMatchingString = (item: Record<string, unknown>): string => {
|
|
|
350
337
|
result = values[0] || ''
|
|
351
338
|
} else {
|
|
352
339
|
// Format PID if second value is 12 digits
|
|
353
|
-
if (values[1]
|
|
340
|
+
if (values[1] && /^\d{8}[a-zA-Z0-9]{4}$/.test(normalizePidSearchValue(values[1]))) {
|
|
354
341
|
values[1] = formatPidWithDash(values[1])
|
|
355
342
|
}
|
|
356
343
|
|
|
@@ -551,7 +538,7 @@ defineExpose<{
|
|
|
551
538
|
:id="inputId"
|
|
552
539
|
:clearButton="!!searchTerm"
|
|
553
540
|
:mask="pidMask"
|
|
554
|
-
:maskOptions="
|
|
541
|
+
:maskOptions="pidMaskOptions"
|
|
555
542
|
v-bind="inputAriaAttrs"
|
|
556
543
|
:searchIcon="!disabled && !hasInputValue"
|
|
557
544
|
@input="handleInput"
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
3
3
|
import { useBoldQuery } from '../../composables/useBoldQuery'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
formatPidWithDash,
|
|
6
|
+
isPidString,
|
|
7
|
+
normalizePidSearchValue,
|
|
8
|
+
PID_MASK,
|
|
9
|
+
PID_MASK_OPTIONS,
|
|
10
|
+
useIsPid,
|
|
11
|
+
} from '../../composables/useIsPid'
|
|
5
12
|
import { useFdsI18n } from '../../plugin/useFdsI18n'
|
|
6
13
|
import FdsButtonIcon from '../Buttons/FdsButtonIcon/FdsButtonIcon.vue'
|
|
7
14
|
import FdsSpinner from '../FdsSpinner/FdsSpinner.vue'
|
|
@@ -279,25 +286,6 @@ watch(
|
|
|
279
286
|
{ immediate: true },
|
|
280
287
|
)
|
|
281
288
|
|
|
282
|
-
const formatPidWithDash = (value: string): string => {
|
|
283
|
-
// Only format if it looks like a personnummer
|
|
284
|
-
if (!isPidString(value)) {
|
|
285
|
-
// If it's not a PID, remove any trailing dash that might be left
|
|
286
|
-
return value.replace(/-$/, '')
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Remove all non-digits
|
|
290
|
-
const digits = value.replace(/\D/g, '')
|
|
291
|
-
|
|
292
|
-
// Only add dash if there are digits after the first 8
|
|
293
|
-
if (digits.length > 8) {
|
|
294
|
-
return `${digits.substring(0, 8)}-${digits.substring(8)}`
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// If exactly 8 digits or less, return without dash
|
|
298
|
-
return digits
|
|
299
|
-
}
|
|
300
|
-
|
|
301
289
|
let changeTimeout: ReturnType<typeof setTimeout> | null = null
|
|
302
290
|
let inputTimeout: ReturnType<typeof setTimeout> | null = null
|
|
303
291
|
const isDebouncingChange = ref(false)
|
|
@@ -380,12 +368,9 @@ const handleInput = (e: Event) => {
|
|
|
380
368
|
const { boldQuery } = useBoldQuery(searchTerm)
|
|
381
369
|
|
|
382
370
|
// Mask configuration for personnummer (yyyymmdd-nnnn)
|
|
383
|
-
const pidMask = computed(() =>
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
387
|
-
return undefined
|
|
388
|
-
})
|
|
371
|
+
const pidMask = computed(() => (isPid.value ? PID_MASK : undefined))
|
|
372
|
+
|
|
373
|
+
const pidMaskOptions = computed(() => (isPid.value ? PID_MASK_OPTIONS : { lazy: true }))
|
|
389
374
|
|
|
390
375
|
const handleMatchingString = (item: Record<string, unknown>): string => {
|
|
391
376
|
if (isDividerItem(item)) {
|
|
@@ -415,7 +400,7 @@ const handleMatchingString = (item: Record<string, unknown>): string => {
|
|
|
415
400
|
result = values[0] ?? ''
|
|
416
401
|
} else {
|
|
417
402
|
// Format PID if second value is 12 digits
|
|
418
|
-
if (values[1]
|
|
403
|
+
if (values[1] && /^\d{8}[a-zA-Z0-9]{4}$/.test(normalizePidSearchValue(values[1]))) {
|
|
419
404
|
values[1] = formatPidWithDash(values[1])
|
|
420
405
|
}
|
|
421
406
|
|
|
@@ -475,8 +460,7 @@ function resolveInitialSingleSelection() {
|
|
|
475
460
|
if (!isPidString(trimmed)) {
|
|
476
461
|
return trimmed.replace(/-$/, '').toLowerCase()
|
|
477
462
|
}
|
|
478
|
-
|
|
479
|
-
return (digits.length > 8 ? `${digits.substring(0, 8)}-${digits.substring(8)}` : digits).toLowerCase()
|
|
463
|
+
return formatPidWithDash(trimmed).toLowerCase()
|
|
480
464
|
}
|
|
481
465
|
|
|
482
466
|
const normalizedInitialValue = normalizeForInitialMatch(props.initialValue)
|
|
@@ -853,7 +837,7 @@ defineExpose<{
|
|
|
853
837
|
:id="inputId"
|
|
854
838
|
:clearButton="!!searchTerm || hasSelection"
|
|
855
839
|
:mask="pidMask"
|
|
856
|
-
:maskOptions="
|
|
840
|
+
:maskOptions="pidMaskOptions"
|
|
857
841
|
v-bind="inputAriaAttrs"
|
|
858
842
|
:searchIcon="!disabled && !inputHasFocus && !hasInputValue && !hasSelection && !dropdownVisible"
|
|
859
843
|
@input="handleInput"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
|
2
|
+
import { normalizePidSearchValue } from '../../composables/useIsPid'
|
|
2
3
|
import { useFdsI18n } from '../../plugin/useFdsI18n'
|
|
3
4
|
import type { FdsSearchSelectProProps } from './types'
|
|
4
5
|
|
|
@@ -261,7 +262,7 @@ export const useSearchSelectProItems = ({
|
|
|
261
262
|
if (bypassSearchFilter.value) return true
|
|
262
263
|
if (!searchTerm.value) return true
|
|
263
264
|
|
|
264
|
-
const searchValue = isPid.value ? searchTerm.value
|
|
265
|
+
const searchValue = isPid.value ? normalizePidSearchValue(searchTerm.value) : searchTerm.value
|
|
265
266
|
const searchLower = searchValue.toLowerCase()
|
|
266
267
|
const pidRegex = /^\d{8}[a-zA-Z0-9]{4}$/
|
|
267
268
|
if (pidRegex.test(searchValue)) return true
|
|
@@ -272,8 +273,13 @@ export const useSearchSelectProItems = ({
|
|
|
272
273
|
|
|
273
274
|
const stringValue = String(value)
|
|
274
275
|
const valueLower = stringValue.toLowerCase()
|
|
275
|
-
const unmaskedValue =
|
|
276
|
-
|
|
276
|
+
const unmaskedValue = isPid.value
|
|
277
|
+
? normalizePidSearchValue(stringValue)
|
|
278
|
+
: stringValue.replace(/\D/g, '')
|
|
279
|
+
return (
|
|
280
|
+
valueLower.includes(searchLower) ||
|
|
281
|
+
(unmaskedValue.length > 0 && unmaskedValue.toLowerCase().includes(searchLower))
|
|
282
|
+
)
|
|
277
283
|
})
|
|
278
284
|
}
|
|
279
285
|
|
|
@@ -281,7 +287,7 @@ export const useSearchSelectProItems = ({
|
|
|
281
287
|
if (bypassSearchFilter.value) return false
|
|
282
288
|
if (!isDividerItem(item) || !searchTerm.value) return false
|
|
283
289
|
|
|
284
|
-
const searchValue = isPid.value ? searchTerm.value
|
|
290
|
+
const searchValue = isPid.value ? normalizePidSearchValue(searchTerm.value) : searchTerm.value
|
|
285
291
|
const searchLower = searchValue.toLowerCase()
|
|
286
292
|
const dividerSearchFields = [...searchFields.value, 'label', 'name']
|
|
287
293
|
|
|
@@ -503,7 +503,7 @@ defineSlots<{
|
|
|
503
503
|
</span>
|
|
504
504
|
<span
|
|
505
505
|
:ref="(el) => setStepLabelRef(el, visibleIndex)"
|
|
506
|
-
class="absolute bottom-0 text-blue-600 font-bold text-sm"
|
|
506
|
+
class="absolute bottom-0 text-blue-600 font-bold text-sm whitespace-nowrap"
|
|
507
507
|
:class="{ invisible: !showStepLabels }"
|
|
508
508
|
>
|
|
509
509
|
{{ entry.route.meta.wizard.name }}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import FdsPhonenumber from './FdsPhonenumber.vue'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof FdsPhonenumber> = {
|
|
6
|
+
title: 'FDS/Form/FdsPhonenumber',
|
|
7
|
+
component: FdsPhonenumber,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
argTypes: {
|
|
10
|
+
label: { control: 'text' },
|
|
11
|
+
meta: { control: 'text' },
|
|
12
|
+
disabled: { control: 'boolean' },
|
|
13
|
+
optional: { control: 'boolean' },
|
|
14
|
+
valid: { control: 'select', options: [undefined, true, false, null] },
|
|
15
|
+
invalidMessage: { control: 'text' },
|
|
16
|
+
defaultCountry: { control: 'text' },
|
|
17
|
+
locale: { control: 'text' },
|
|
18
|
+
numberType: { control: 'select', options: ['mobile', 'any'] },
|
|
19
|
+
},
|
|
20
|
+
args: {
|
|
21
|
+
label: 'Telefonnummer',
|
|
22
|
+
meta: '',
|
|
23
|
+
disabled: false,
|
|
24
|
+
optional: false,
|
|
25
|
+
valid: undefined,
|
|
26
|
+
invalidMessage: 'Ange ett giltigt telefonnummer',
|
|
27
|
+
defaultCountry: 'SE',
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default meta
|
|
32
|
+
type Story = StoryObj<typeof meta>
|
|
33
|
+
|
|
34
|
+
export const Default: Story = {
|
|
35
|
+
render: (args) => ({
|
|
36
|
+
components: { FdsPhonenumber },
|
|
37
|
+
setup() {
|
|
38
|
+
const number = ref('')
|
|
39
|
+
const country = ref('SE')
|
|
40
|
+
const phoneValid = ref<boolean | null>(null)
|
|
41
|
+
const e164 = ref('')
|
|
42
|
+
return { args, number, country, phoneValid, e164 }
|
|
43
|
+
},
|
|
44
|
+
template: `
|
|
45
|
+
<div>
|
|
46
|
+
<FdsPhonenumber
|
|
47
|
+
v-bind="args"
|
|
48
|
+
v-model="number"
|
|
49
|
+
v-model:country="country"
|
|
50
|
+
@valid="phoneValid = $event"
|
|
51
|
+
@update:e164="e164 = $event"
|
|
52
|
+
/>
|
|
53
|
+
<p class="mt-4 text-sm">Nummer: {{ number }}</p>
|
|
54
|
+
<p class="text-sm">Land: {{ country }}</p>
|
|
55
|
+
<p class="text-sm">valid: {{ phoneValid }}</p>
|
|
56
|
+
<p class="text-sm">E.164: {{ e164 || '—' }}</p>
|
|
57
|
+
</div>
|
|
58
|
+
`,
|
|
59
|
+
}),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const InvalidNumber: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
valid: false,
|
|
65
|
+
},
|
|
66
|
+
render: (args) => ({
|
|
67
|
+
components: { FdsPhonenumber },
|
|
68
|
+
setup() {
|
|
69
|
+
const number = ref('123')
|
|
70
|
+
const country = ref('SE')
|
|
71
|
+
return { args, number, country }
|
|
72
|
+
},
|
|
73
|
+
template: `
|
|
74
|
+
<FdsPhonenumber
|
|
75
|
+
v-bind="args"
|
|
76
|
+
v-model="number"
|
|
77
|
+
v-model:country="country"
|
|
78
|
+
/>
|
|
79
|
+
`,
|
|
80
|
+
}),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const AllowAnyNumberType: Story = {
|
|
84
|
+
args: {
|
|
85
|
+
numberType: 'any',
|
|
86
|
+
meta: 'Mobil och fast telefon accepteras (t.ex. Stockholm 08 + 6–7 siffror)',
|
|
87
|
+
},
|
|
88
|
+
render: (args) => ({
|
|
89
|
+
components: { FdsPhonenumber },
|
|
90
|
+
setup() {
|
|
91
|
+
// Stockholm: 08 + 7 siffror
|
|
92
|
+
const number = ref('081234567')
|
|
93
|
+
const country = ref('SE')
|
|
94
|
+
const phoneValid = ref<boolean | null>(null)
|
|
95
|
+
const e164 = ref('')
|
|
96
|
+
return { args, number, country, phoneValid, e164 }
|
|
97
|
+
},
|
|
98
|
+
template: `
|
|
99
|
+
<div>
|
|
100
|
+
<FdsPhonenumber
|
|
101
|
+
v-bind="args"
|
|
102
|
+
v-model="number"
|
|
103
|
+
v-model:country="country"
|
|
104
|
+
@valid="phoneValid = $event"
|
|
105
|
+
@update:e164="e164 = $event"
|
|
106
|
+
/>
|
|
107
|
+
<p class="mt-4 text-sm">valid: {{ phoneValid }}</p>
|
|
108
|
+
<p class="text-sm">E.164: {{ e164 || '—' }}</p>
|
|
109
|
+
</div>
|
|
110
|
+
`,
|
|
111
|
+
}),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const EnglishLocale: Story = {
|
|
115
|
+
args: {
|
|
116
|
+
locale: 'en',
|
|
117
|
+
},
|
|
118
|
+
render: (args) => ({
|
|
119
|
+
components: { FdsPhonenumber },
|
|
120
|
+
setup() {
|
|
121
|
+
const number = ref('')
|
|
122
|
+
const country = ref('SE')
|
|
123
|
+
return { args, number, country }
|
|
124
|
+
},
|
|
125
|
+
template: `
|
|
126
|
+
<FdsPhonenumber
|
|
127
|
+
v-bind="args"
|
|
128
|
+
v-model="number"
|
|
129
|
+
v-model:country="country"
|
|
130
|
+
/>
|
|
131
|
+
`,
|
|
132
|
+
}),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const ValidSwedishNumber: Story = {
|
|
136
|
+
render: (args) => ({
|
|
137
|
+
components: { FdsPhonenumber },
|
|
138
|
+
setup() {
|
|
139
|
+
const number = ref('0701234567')
|
|
140
|
+
const country = ref('SE')
|
|
141
|
+
const e164 = ref('')
|
|
142
|
+
return { args, number, country, e164 }
|
|
143
|
+
},
|
|
144
|
+
template: `
|
|
145
|
+
<div>
|
|
146
|
+
<FdsPhonenumber
|
|
147
|
+
v-bind="args"
|
|
148
|
+
v-model="number"
|
|
149
|
+
v-model:country="country"
|
|
150
|
+
@update:e164="e164 = $event"
|
|
151
|
+
/>
|
|
152
|
+
<p class="mt-4 text-sm">E.164: {{ e164 }}</p>
|
|
153
|
+
</div>
|
|
154
|
+
`,
|
|
155
|
+
}),
|
|
156
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue'
|
|
3
|
+
import { useFdsI18n } from '../../../plugin/useFdsI18n'
|
|
4
|
+
import FdsInput from '../FdsInput/FdsInput.vue'
|
|
5
|
+
import { buildCountryOptions, sortCountryOptionsByName } from './countries'
|
|
6
|
+
import FdsPhonenumberCountryPicker from './FdsPhonenumberCountryPicker.vue'
|
|
7
|
+
import type { FdsPhonenumberEmits, FdsPhonenumberProps } from './types'
|
|
8
|
+
import { getPhoneValidationState, validatePhoneNumber } from './validatePhone'
|
|
9
|
+
|
|
10
|
+
defineOptions({
|
|
11
|
+
inheritAttrs: false,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const nationalNumber = defineModel<string>({ default: '' })
|
|
15
|
+
const country = defineModel<string>('country', { default: 'SE' })
|
|
16
|
+
|
|
17
|
+
const props = withDefaults(defineProps<FdsPhonenumberProps>(), {
|
|
18
|
+
label: undefined,
|
|
19
|
+
meta: undefined,
|
|
20
|
+
optional: false,
|
|
21
|
+
valid: undefined,
|
|
22
|
+
invalidMessage: undefined,
|
|
23
|
+
defaultCountry: 'SE',
|
|
24
|
+
countries: undefined,
|
|
25
|
+
locale: undefined,
|
|
26
|
+
numberType: 'mobile',
|
|
27
|
+
disabled: false,
|
|
28
|
+
dataTestid: undefined,
|
|
29
|
+
selectClass: undefined,
|
|
30
|
+
inputClass: undefined,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits<FdsPhonenumberEmits>()
|
|
34
|
+
|
|
35
|
+
const { t, locale: fdsLocale } = useFdsI18n()
|
|
36
|
+
|
|
37
|
+
const resolvedLocale = computed(() => props.locale ?? fdsLocale.value)
|
|
38
|
+
|
|
39
|
+
const countryItems = computed(() => {
|
|
40
|
+
const options = props.countries?.length
|
|
41
|
+
? props.countries
|
|
42
|
+
: buildCountryOptions(resolvedLocale.value, props.defaultCountry)
|
|
43
|
+
return sortCountryOptionsByName(
|
|
44
|
+
options.map((option) => ({ ...option })),
|
|
45
|
+
resolvedLocale.value,
|
|
46
|
+
props.defaultCountry,
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const phoneValidationOptions = computed(() => ({ numberType: props.numberType }))
|
|
51
|
+
|
|
52
|
+
/** Set after the phone number field has blurred; validation does not run on input. */
|
|
53
|
+
const committedValid = ref<boolean | null>(null)
|
|
54
|
+
|
|
55
|
+
const displayValid = computed((): boolean | null | undefined => {
|
|
56
|
+
if (props.valid === false) return false
|
|
57
|
+
if (props.valid === true) return true
|
|
58
|
+
if (committedValid.value === false) return false
|
|
59
|
+
if (committedValid.value === true) return true
|
|
60
|
+
return props.valid
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const showInvalidMessage = computed(
|
|
64
|
+
() => displayValid.value === false && !props.optional && props.invalidMessage && !props.disabled,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const noCountryResults = ref(false)
|
|
68
|
+
|
|
69
|
+
function runValidation() {
|
|
70
|
+
const validationState = getPhoneValidationState(
|
|
71
|
+
nationalNumber.value ?? '',
|
|
72
|
+
country.value ?? 'SE',
|
|
73
|
+
phoneValidationOptions.value,
|
|
74
|
+
)
|
|
75
|
+
committedValid.value = validationState
|
|
76
|
+
emit('valid', validationState)
|
|
77
|
+
|
|
78
|
+
const result = validatePhoneNumber(nationalNumber.value ?? '', country.value ?? 'SE', phoneValidationOptions.value)
|
|
79
|
+
emit('update:e164', result.isValid && result.phoneNumber ? result.phoneNumber : '')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
watch([country, () => props.numberType], () => {
|
|
83
|
+
if (committedValid.value === null) {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
runValidation()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
function handleBlur(ev: FocusEvent) {
|
|
90
|
+
runValidation()
|
|
91
|
+
emit('blur', ev)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function onNoCountryResults(value: boolean) {
|
|
95
|
+
noCountryResults.value = value
|
|
96
|
+
emit('noCountryResults', value)
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<template>
|
|
101
|
+
<div class="w-full mb-6">
|
|
102
|
+
<label v-if="label" class="block font-bold text-gray-900 cursor-pointer" :class="{ 'mb-0': meta, 'mb-1': !meta }">
|
|
103
|
+
{{ label }}
|
|
104
|
+
</label>
|
|
105
|
+
<div v-if="meta" class="font-thin mb-1">
|
|
106
|
+
{{ meta }}
|
|
107
|
+
</div>
|
|
108
|
+
<div class="flex flex-wrap items-start gap-x-1">
|
|
109
|
+
<FdsPhonenumberCountryPicker
|
|
110
|
+
v-model="country"
|
|
111
|
+
:items="countryItems"
|
|
112
|
+
:valid="displayValid"
|
|
113
|
+
:disabled="disabled"
|
|
114
|
+
:ariaLabel="t('FdsPhonenumber.countryCode')"
|
|
115
|
+
:data-testid="dataTestid ? `${dataTestid}-country` : undefined"
|
|
116
|
+
:class="['mb-0! shrink-0', selectClass ?? '']"
|
|
117
|
+
@no-country-results="onNoCountryResults"
|
|
118
|
+
/>
|
|
119
|
+
<FdsInput
|
|
120
|
+
v-model="nationalNumber"
|
|
121
|
+
type="tel"
|
|
122
|
+
:valid="displayValid"
|
|
123
|
+
:disabled="disabled"
|
|
124
|
+
:optional="optional"
|
|
125
|
+
:ariaLabel="t('FdsPhonenumber.phoneNumber')"
|
|
126
|
+
:data-testid="dataTestid ? `${dataTestid}-number` : undefined"
|
|
127
|
+
:class="['mb-0! min-w-0 flex-1', inputClass ?? '']"
|
|
128
|
+
@blur="handleBlur"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
<div v-if="noCountryResults" class="text-red-700 font-bold mt-1">
|
|
132
|
+
{{ t('FdsPhonenumber.noCountryResults') }}
|
|
133
|
+
</div>
|
|
134
|
+
<div v-if="showInvalidMessage" class="text-red-700 font-bold mt-1">
|
|
135
|
+
{{ invalidMessage }}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</template>
|