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.
Files changed (30) hide show
  1. package/components.d.ts +2 -0
  2. package/dist/components/Form/FdsPhonenumber/FdsPhonenumber.stories.d.ts +10 -0
  3. package/dist/components/Form/FdsPhonenumber/FdsPhonenumber.vue.d.ts +36 -0
  4. package/dist/components/Form/FdsPhonenumber/FdsPhonenumberCountryPicker.vue.d.ts +30 -0
  5. package/dist/components/Form/FdsPhonenumber/countries.d.ts +14 -0
  6. package/dist/components/Form/FdsPhonenumber/types.d.ts +36 -0
  7. package/dist/components/Form/FdsPhonenumber/validatePhone.d.ts +16 -0
  8. package/dist/composables/useIsPid.d.ts +11 -0
  9. package/dist/fds-vue-core.cjs.js +3396 -613
  10. package/dist/fds-vue-core.cjs.js.map +1 -1
  11. package/dist/fds-vue-core.css +1 -1
  12. package/dist/fds-vue-core.es.js +3397 -614
  13. package/dist/fds-vue-core.es.js.map +1 -1
  14. package/dist/index.d.ts +8 -3
  15. package/dist/tsconfig.build.tsbuildinfo +1 -1
  16. package/package.json +19 -18
  17. package/src/components/FdsSearchSelect/FdsSearchSelect.vue +18 -31
  18. package/src/components/FdsSearchSelectPro/FdsSearchSelectPro.vue +14 -30
  19. package/src/components/FdsSearchSelectPro/useSearchSelectProItems.ts +10 -4
  20. package/src/components/FdsWizard/FdsWizard.vue +1 -1
  21. package/src/components/Form/FdsPhonenumber/FdsPhonenumber.stories.ts +156 -0
  22. package/src/components/Form/FdsPhonenumber/FdsPhonenumber.vue +138 -0
  23. package/src/components/Form/FdsPhonenumber/FdsPhonenumberCountryPicker.vue +426 -0
  24. package/src/components/Form/FdsPhonenumber/countries.ts +111 -0
  25. package/src/components/Form/FdsPhonenumber/types.ts +39 -0
  26. package/src/components/Form/FdsPhonenumber/validatePhone.ts +107 -0
  27. package/src/composables/useIsPid.ts +49 -11
  28. package/src/index.ts +36 -1
  29. package/src/lang/en.json +4 -0
  30. 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.1.9",
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.0",
52
- "date-fns": "4.1.0",
51
+ "axios": "1.16.1",
52
+ "date-fns": "4.2.1",
53
53
  "imask": "7.6.1",
54
- "tailwindcss": "4.2.4",
55
- "vue-i18n": "11.3.2"
54
+ "libphonenumber-js": "1.12.36",
55
+ "tailwindcss": "4.3.0",
56
+ "vue-i18n": "11.4.4"
56
57
  },
57
58
  "devDependencies": {
58
- "@intlify/unplugin-vue-i18n": "11.0.7",
59
- "@chromatic-com/storybook": "5.0.1",
60
- "@storybook/addon-a11y": "10.1.10",
61
- "@storybook/addon-docs": "10.1.10",
62
- "@storybook/addon-vitest": "10.2.12",
63
- "@storybook/vue3": "10.2.12",
64
- "@storybook/vue3-vite": "10.2.12",
65
- "@tailwindcss/vite": "4.2.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.6",
68
+ "@vitejs/plugin-vue": "6.0.7",
68
69
  "@vitest/browser": "3.2.4",
69
- "fg-devkit": "1.8.4",
70
- "storybook": "10.2.12",
71
- "tsx": "4.21.0",
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.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 { isPidString, useIsPid } from '../../composables/useIsPid'
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.replace(/\D/g, '') : 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 valueLower.includes(searchLower) || (unmaskedValue.length > 0 && unmaskedValue.includes(searchValue))
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
- if (isPid.value) {
340
- return '00000000-0000'
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]?.length === 12 && parseInt(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="{ lazy: true }"
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 { isPidString, useIsPid } from '../../composables/useIsPid'
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
- if (isPid.value) {
385
- return '00000000-0000'
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]?.length === 12 && parseInt(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
- const digits = trimmed.replace(/\D/g, '')
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="{ lazy: true }"
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.replace(/\D/g, '') : 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 = stringValue.replace(/\D/g, '')
276
- return valueLower.includes(searchLower) || (unmaskedValue.length > 0 && unmaskedValue.includes(searchValue))
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.replace(/\D/g, '') : 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>