fds-vue-core 7.2.1 β†’ 7.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fds-vue-core",
3
- "version": "7.2.1",
3
+ "version": "7.2.3",
4
4
  "description": "FDS Vue Core Component Library",
5
5
  "type": "module",
6
6
  "main": "./dist/fds-vue-core.cjs.js",
@@ -48,31 +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
54
  "libphonenumber-js": "1.12.36",
55
- "tailwindcss": "4.2.4",
56
- "vue-i18n": "11.3.2"
55
+ "tailwindcss": "4.3.0",
56
+ "vue-i18n": "11.4.4"
57
57
  },
58
58
  "devDependencies": {
59
- "@chromatic-com/storybook": "5.0.1",
60
- "@intlify/unplugin-vue-i18n": "11.0.7",
61
- "@storybook/addon-a11y": "10.1.10",
62
- "@storybook/addon-docs": "10.1.10",
63
- "@storybook/addon-vitest": "10.2.12",
64
- "@storybook/vue3": "10.2.12",
65
- "@storybook/vue3-vite": "10.2.12",
66
- "@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",
67
67
  "@types/node": "22.16.5",
68
- "@vitejs/plugin-vue": "6.0.6",
68
+ "@vitejs/plugin-vue": "6.0.7",
69
69
  "@vitest/browser": "3.2.4",
70
- "fg-devkit": "1.8.4",
71
- "storybook": "10.2.12",
72
- "tsx": "4.21.0",
70
+ "fg-devkit": "1.8.5",
71
+ "storybook": "10.4.0",
72
+ "tsx": "4.22.3",
73
73
  "vite": "7.3.2",
74
74
  "vite-plugin-dts": "4.5.4",
75
- "vite-plugin-vue-devtools": "8.1.1",
75
+ "vite-plugin-vue-devtools": "8.1.2",
76
76
  "vitest": "3.2.4",
77
77
  "vue": "3.5.34"
78
78
  },
@@ -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 }}
@@ -19,7 +19,7 @@ const meta: Meta<typeof FdsPhonenumber> = {
19
19
  },
20
20
  args: {
21
21
  label: 'Telefonnummer',
22
- meta: 'Mobilnummer med landskod',
22
+ meta: '',
23
23
  disabled: false,
24
24
  optional: false,
25
25
  valid: undefined,
@@ -37,10 +37,13 @@ const { t, locale: fdsLocale } = useFdsI18n()
37
37
  const resolvedLocale = computed(() => props.locale ?? fdsLocale.value)
38
38
 
39
39
  const countryItems = computed(() => {
40
- const options = props.countries?.length ? props.countries : buildCountryOptions(resolvedLocale.value)
40
+ const options = props.countries?.length
41
+ ? props.countries
42
+ : buildCountryOptions(resolvedLocale.value, props.defaultCountry)
41
43
  return sortCountryOptionsByName(
42
44
  options.map((option) => ({ ...option })),
43
45
  resolvedLocale.value,
46
+ props.defaultCountry,
44
47
  )
45
48
  })
46
49
 
@@ -61,6 +64,8 @@ const showInvalidMessage = computed(
61
64
  () => displayValid.value === false && !props.optional && props.invalidMessage && !props.disabled,
62
65
  )
63
66
 
67
+ const noCountryResults = ref(false)
68
+
64
69
  function runValidation() {
65
70
  const validationState = getPhoneValidationState(
66
71
  nationalNumber.value ?? '',
@@ -85,6 +90,11 @@ function handleBlur(ev: FocusEvent) {
85
90
  runValidation()
86
91
  emit('blur', ev)
87
92
  }
93
+
94
+ function onNoCountryResults(value: boolean) {
95
+ noCountryResults.value = value
96
+ emit('noCountryResults', value)
97
+ }
88
98
  </script>
89
99
 
90
100
  <template>
@@ -95,16 +105,16 @@ function handleBlur(ev: FocusEvent) {
95
105
  <div v-if="meta" class="font-thin mb-1">
96
106
  {{ meta }}
97
107
  </div>
98
- <div class="flex flex-wrap items-start">
108
+ <div class="flex flex-wrap items-start gap-x-1">
99
109
  <FdsPhonenumberCountryPicker
100
110
  v-model="country"
101
111
  :items="countryItems"
102
112
  :valid="displayValid"
103
113
  :disabled="disabled"
104
114
  :ariaLabel="t('FdsPhonenumber.countryCode')"
105
- :no-results-label="t('FdsPhonenumber.noCountryResults')"
106
115
  :data-testid="dataTestid ? `${dataTestid}-country` : undefined"
107
116
  :class="['mb-0! shrink-0', selectClass ?? '']"
117
+ @no-country-results="onNoCountryResults"
108
118
  />
109
119
  <FdsInput
110
120
  v-model="nationalNumber"
@@ -116,9 +126,11 @@ function handleBlur(ev: FocusEvent) {
116
126
  :data-testid="dataTestid ? `${dataTestid}-number` : undefined"
117
127
  :class="['mb-0! min-w-0 flex-1', inputClass ?? '']"
118
128
  @blur="handleBlur"
119
- :inputClass="[' rounded-l-none']"
120
129
  />
121
130
  </div>
131
+ <div v-if="noCountryResults" class="text-red-700 font-bold mt-1">
132
+ {{ t('FdsPhonenumber.noCountryResults') }}
133
+ </div>
122
134
  <div v-if="showInvalidMessage" class="text-red-700 font-bold mt-1">
123
135
  {{ invalidMessage }}
124
136
  </div>
@@ -1,20 +1,23 @@
1
1
  <script setup lang="ts">
2
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type HTMLAttributes } from 'vue'
2
+ import { computed, onBeforeUnmount, onMounted, ref, watch, type HTMLAttributes } from 'vue'
3
3
  import { useFdsI18n } from '../../../plugin/useFdsI18n'
4
4
  import FdsIcon from '../../FdsIcon/FdsIcon.vue'
5
- import { countryCodeToFlag, filterCountryOptions, type CountryPhoneOption } from './countries'
5
+ import { countryCodeToFlag, filterCountryOptions, isPrioritizedCountry, type CountryPhoneOption } from './countries'
6
6
 
7
7
  const { t } = useFdsI18n()
8
8
 
9
9
  const country = defineModel<string>({ required: true })
10
10
 
11
+ const emit = defineEmits<{
12
+ noCountryResults: [value: boolean]
13
+ }>()
14
+
11
15
  const props = withDefaults(
12
16
  defineProps<{
13
17
  items: CountryPhoneOption[]
14
18
  valid?: boolean | null
15
19
  disabled?: boolean
16
20
  ariaLabel?: string
17
- noResultsLabel?: string
18
21
  dataTestid?: string
19
22
  class?: HTMLAttributes['class']
20
23
  }>(),
@@ -22,53 +25,104 @@ const props = withDefaults(
22
25
  valid: undefined,
23
26
  disabled: false,
24
27
  ariaLabel: undefined,
25
- noResultsLabel: '',
26
28
  dataTestid: undefined,
27
29
  class: undefined,
28
30
  },
29
31
  )
30
32
 
31
33
  const rootRef = ref<HTMLElement | null>(null)
32
- const listRef = ref<HTMLUListElement | null>(null)
33
34
  const inputRef = ref<HTMLInputElement | null>(null)
34
35
  const searchQuery = ref('')
35
36
  const dropdownOpen = ref(false)
36
37
  const isFocused = ref(false)
38
+ const hoveredCountry = ref<CountryPhoneOption | null>(null)
39
+ const activeIndex = ref(-1)
40
+
41
+ const listboxId = `fds-phone-country-listbox-${Math.random().toString(36).slice(2, 9)}`
37
42
 
38
43
  const selectedCountry = computed(() => props.items.find((item) => item.value === country.value))
39
44
 
40
45
  const filteredCountries = computed(() => filterCountryOptions(props.items, searchQuery.value))
41
46
 
47
+ const lastPrioritizedFilteredIndex = computed(() =>
48
+ filteredCountries.value.reduce(
49
+ (lastIndex, option, index) => (isPrioritizedCountry(option.value) ? index : lastIndex),
50
+ -1,
51
+ ),
52
+ )
53
+
54
+ const hasNoCountryResults = computed(
55
+ () =>
56
+ dropdownOpen.value &&
57
+ !props.disabled &&
58
+ searchQuery.value.trim().length > 0 &&
59
+ filteredCountries.value.length === 0,
60
+ )
61
+
42
62
  const isInvalid = computed(() => props.valid === false && !props.disabled)
43
63
 
44
- const showSelectedPreview = computed(() => !dropdownOpen.value && !isFocused.value && !!selectedCountry.value)
64
+ const isSearching = computed(() => searchQuery.value.length > 0)
65
+
66
+ const isHoverPreview = computed(() => !!hoveredCountry.value && dropdownOpen.value)
67
+
68
+ const activeCountry = computed(() => {
69
+ if (activeIndex.value < 0) {
70
+ return null
71
+ }
72
+ return filteredCountries.value[activeIndex.value] ?? null
73
+ })
74
+
75
+ const showSelectedDialPreview = computed(() => !!selectedCountry.value && !isSearching.value && !isHoverPreview.value)
76
+
77
+ const showHoverDialPreview = computed(() => isHoverPreview.value && !!hoveredCountry.value)
78
+
79
+ const showActiveDialPreview = computed(
80
+ () => !showHoverDialPreview.value && !!activeCountry.value && dropdownOpen.value && !isSearching.value,
81
+ )
45
82
 
46
- const inputDisplayValue = computed(() => {
47
- if (dropdownOpen.value || isFocused.value) {
48
- return searchQuery.value
83
+ const dialPreviewCountry = computed(() => {
84
+ if (showHoverDialPreview.value && hoveredCountry.value) {
85
+ return hoveredCountry.value
49
86
  }
50
- if (!selectedCountry.value) {
51
- return ''
87
+ if (showActiveDialPreview.value && activeCountry.value) {
88
+ return activeCountry.value
52
89
  }
53
- return `+${selectedCountry.value.countryCode}`
90
+ if (showSelectedDialPreview.value && selectedCountry.value) {
91
+ return selectedCountry.value
92
+ }
93
+ return null
54
94
  })
55
95
 
96
+ const activeDescendantId = computed(() =>
97
+ activeIndex.value >= 0 ? `fds-phone-country-option-${activeIndex.value}` : undefined,
98
+ )
99
+
56
100
  const inputDataAttrs = computed(() => (props.dataTestid ? { 'data-testid': props.dataTestid } : {}))
57
101
 
58
102
  const listDataAttrs = computed(() => (props.dataTestid ? { 'data-testid': `${props.dataTestid}-list` } : {}))
59
103
 
104
+ const dialPreviewClasses = computed(() => [
105
+ 'pointer-events-none absolute inset-0 flex items-center pl-10 pr-10 text-base leading-6 tabular-nums',
106
+ showHoverDialPreview.value ? 'text-gray-900/50' : 'text-gray-900',
107
+ ])
108
+
60
109
  const inputClasses = computed(() => [
61
- 'block w-full h-12 rounded-md border border-gray-500 py-2 text-base leading-6 tabular-nums',
62
- showSelectedPreview.value ? 'pl-10 pr-10' : 'px-3 pr-10',
110
+ 'block w-full h-12 rounded-md border border-gray-500 py-2 text-base leading-6 tabular-nums px-3 pr-10',
63
111
  'focus:outline-2 focus:outline-blue-500 -outline-offset-2 focus:border-transparent',
64
112
  props.disabled
65
113
  ? 'text-gray-800 outline-dashed outline-2 outline-gray-400 cursor-not-allowed border-transparent bg-gray-50'
66
- : 'bg-white text-gray-900',
114
+ : 'cursor-text bg-white text-gray-900',
67
115
  isInvalid.value && 'outline-2 -outline-offset-2 outline-red-600',
116
+ hasNoCountryResults.value && 'outline-red-600!',
117
+ ])
118
+
119
+ const arrowButtonClasses = computed(() => [
120
+ 'absolute right-0 top-0 z-10 flex h-12 w-10 items-center justify-center border-0 bg-transparent p-0',
121
+ props.disabled ? 'cursor-not-allowed' : 'cursor-pointer',
68
122
  ])
69
123
 
70
124
  const arrowClasses = computed(() => [
71
- 'transition-transform duration-200 ease-in-out',
125
+ 'pointer-events-none transition-transform duration-200 ease-in-out',
72
126
  {
73
127
  'fill-gray-500': props.disabled,
74
128
  'fill-red-600': isInvalid.value && !props.disabled,
@@ -77,48 +131,76 @@ const arrowClasses = computed(() => [
77
131
  },
78
132
  ])
79
133
 
80
- async function scrollToSelectedCountry() {
81
- if (!country.value) {
82
- return
83
- }
84
- await nextTick()
85
- const list = listRef.value
86
- if (!list) {
87
- return
88
- }
89
- const selected = list.querySelector<HTMLElement>(`#fds-country-${country.value}`)
90
- if (!selected) {
134
+ const openDropdown = () => {
135
+ if (props.disabled) {
91
136
  return
92
137
  }
138
+ dropdownOpen.value = true
139
+ isFocused.value = true
140
+ searchQuery.value = ''
141
+ activeIndex.value = -1
142
+ }
93
143
 
94
- // Scroll only inside the list β€” scrollIntoView would move the whole page.
95
- const listRect = list.getBoundingClientRect()
96
- const itemRect = selected.getBoundingClientRect()
97
- const relativeTop = itemRect.top - listRect.top + list.scrollTop
98
- list.scrollTop = relativeTop - (list.clientHeight - selected.offsetHeight) / 2
144
+ const clearHoverPreview = () => {
145
+ hoveredCountry.value = null
99
146
  }
100
147
 
101
- async function openDropdown() {
102
- if (props.disabled) {
148
+ const clearActiveOption = () => {
149
+ activeIndex.value = -1
150
+ }
151
+
152
+ const setActiveIndex = (index: number) => {
153
+ if (!filteredCountries.value.length) {
154
+ clearActiveOption()
103
155
  return
104
156
  }
105
- dropdownOpen.value = true
106
- isFocused.value = true
107
- searchQuery.value = ''
108
- await scrollToSelectedCountry()
157
+ activeIndex.value = Math.max(0, Math.min(index, filteredCountries.value.length - 1))
158
+ }
159
+
160
+ const setActiveIndexToSelectedOrFirst = () => {
161
+ const selectedIndex = filteredCountries.value.findIndex((option) => option.value === country.value)
162
+ setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0)
163
+ }
164
+
165
+ const setActiveIndexToSelectedOrLast = () => {
166
+ const selectedIndex = filteredCountries.value.findIndex((option) => option.value === country.value)
167
+ setActiveIndex(selectedIndex >= 0 ? selectedIndex : filteredCountries.value.length - 1)
109
168
  }
110
169
 
111
- function closeDropdown() {
170
+ const onOptionMouseEnter = (option: CountryPhoneOption) => {
171
+ hoveredCountry.value = option
172
+ }
173
+
174
+ const onOptionMouseLeave = () => {
175
+ clearHoverPreview()
176
+ }
177
+
178
+ const closeDropdown = () => {
112
179
  dropdownOpen.value = false
113
180
  isFocused.value = false
114
181
  searchQuery.value = ''
182
+ clearHoverPreview()
183
+ clearActiveOption()
184
+ }
185
+
186
+ const onInputFocus = () => {
187
+ openDropdown()
115
188
  }
116
189
 
117
- function onInputFocus() {
190
+ const onArrowPointerDown = () => {
191
+ if (props.disabled) {
192
+ return
193
+ }
194
+ if (dropdownOpen.value) {
195
+ closeDropdown()
196
+ inputRef.value?.blur()
197
+ return
198
+ }
118
199
  openDropdown()
200
+ inputRef.value?.focus()
119
201
  }
120
202
 
121
- function onInputBlur() {
203
+ const onInputBlur = () => {
122
204
  isFocused.value = false
123
205
  window.setTimeout(() => {
124
206
  if (!dropdownOpen.value) {
@@ -128,21 +210,86 @@ function onInputBlur() {
128
210
  }, 120)
129
211
  }
130
212
 
131
- function onSearchInput(event: Event) {
213
+ const onSearchInput = (event: Event) => {
132
214
  const target = event.target as HTMLInputElement
133
215
  searchQuery.value = target.value
216
+ clearActiveOption()
134
217
  if (!dropdownOpen.value) {
135
218
  dropdownOpen.value = true
136
219
  }
137
220
  }
138
221
 
139
- function selectCountry(option: CountryPhoneOption) {
222
+ const onInputKeyDown = (event: KeyboardEvent) => {
223
+ if (props.disabled) {
224
+ return
225
+ }
226
+
227
+ if (event.key === 'ArrowDown' && filteredCountries.value.length > 0) {
228
+ event.preventDefault()
229
+ clearHoverPreview()
230
+ if (!dropdownOpen.value) {
231
+ openDropdown()
232
+ setActiveIndexToSelectedOrFirst()
233
+ return
234
+ }
235
+ if (activeIndex.value < 0) {
236
+ setActiveIndexToSelectedOrFirst()
237
+ return
238
+ }
239
+ setActiveIndex(activeIndex.value + 1)
240
+ return
241
+ }
242
+
243
+ if (event.key === 'ArrowUp' && filteredCountries.value.length > 0) {
244
+ event.preventDefault()
245
+ clearHoverPreview()
246
+ if (!dropdownOpen.value) {
247
+ openDropdown()
248
+ setActiveIndexToSelectedOrLast()
249
+ return
250
+ }
251
+ if (activeIndex.value < 0) {
252
+ setActiveIndexToSelectedOrLast()
253
+ return
254
+ }
255
+ setActiveIndex(activeIndex.value - 1)
256
+ return
257
+ }
258
+
259
+ if (event.key === 'Enter') {
260
+ if (!dropdownOpen.value && filteredCountries.value.length > 0) {
261
+ event.preventDefault()
262
+ openDropdown()
263
+ setActiveIndexToSelectedOrFirst()
264
+ return
265
+ }
266
+
267
+ if (dropdownOpen.value && activeCountry.value) {
268
+ event.preventDefault()
269
+ selectCountry(activeCountry.value)
270
+ }
271
+ return
272
+ }
273
+
274
+ if (event.key === 'Escape' && dropdownOpen.value) {
275
+ event.preventDefault()
276
+ closeDropdown()
277
+ inputRef.value?.focus()
278
+ return
279
+ }
280
+
281
+ if (event.key === 'Tab' && dropdownOpen.value) {
282
+ closeDropdown()
283
+ }
284
+ }
285
+
286
+ const selectCountry = (option: CountryPhoneOption) => {
140
287
  country.value = option.value
141
288
  closeDropdown()
142
289
  inputRef.value?.blur()
143
290
  }
144
291
 
145
- function onClickOutside(event: MouseEvent) {
292
+ const onClickOutside = (event: MouseEvent) => {
146
293
  if (!dropdownOpen.value || !rootRef.value) {
147
294
  return
148
295
  }
@@ -160,6 +307,24 @@ watch(
160
307
  },
161
308
  )
162
309
 
310
+ watch(filteredCountries, (options) => {
311
+ if (!options.length) {
312
+ clearActiveOption()
313
+ return
314
+ }
315
+ if (activeIndex.value >= options.length) {
316
+ setActiveIndex(options.length - 1)
317
+ }
318
+ })
319
+
320
+ watch(
321
+ hasNoCountryResults,
322
+ (value) => {
323
+ emit('noCountryResults', value)
324
+ },
325
+ { immediate: true },
326
+ )
327
+
163
328
  onMounted(() => {
164
329
  window.addEventListener('mousedown', onClickOutside)
165
330
  })
@@ -172,13 +337,12 @@ onBeforeUnmount(() => {
172
337
  <template>
173
338
  <div ref="rootRef" class="relative w-32" :class="props.class">
174
339
  <div class="relative w-full">
175
- <span
176
- v-if="showSelectedPreview && selectedCountry"
177
- class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-xl leading-none"
178
- aria-hidden="true"
179
- >
180
- {{ countryCodeToFlag(selectedCountry.value) }}
181
- </span>
340
+ <div v-if="!dropdownOpen" :class="dialPreviewClasses" aria-hidden="true">
341
+ <span class="absolute left-3 top-1/2 -translate-y-1/2 text-xl leading-none">
342
+ {{ countryCodeToFlag(dialPreviewCountry?.value ?? '') }}
343
+ </span>
344
+ {{ t('common.plus') }}{{ dialPreviewCountry?.countryCode ?? '' }}
345
+ </div>
182
346
  <input
183
347
  ref="inputRef"
184
348
  type="search"
@@ -186,51 +350,70 @@ onBeforeUnmount(() => {
186
350
  role="combobox"
187
351
  :aria-expanded="dropdownOpen"
188
352
  aria-autocomplete="list"
353
+ aria-haspopup="listbox"
189
354
  :aria-label="ariaLabel"
190
- :value="inputDisplayValue"
355
+ :aria-controls="dropdownOpen ? listboxId : undefined"
356
+ :aria-activedescendant="activeDescendantId"
357
+ :value="searchQuery"
191
358
  :disabled="disabled"
192
359
  :class="inputClasses"
193
360
  v-bind="inputDataAttrs"
194
361
  @focus="onInputFocus"
195
362
  @blur="onInputBlur"
363
+ @keydown="onInputKeyDown"
196
364
  @input="onSearchInput"
197
- class="border-r-0 rounded-r-none"
198
365
  />
199
- <div class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
366
+ <button
367
+ type="button"
368
+ tabindex="-1"
369
+ :disabled="disabled"
370
+ :class="arrowButtonClasses"
371
+ aria-hidden="true"
372
+ @mousedown.prevent="onArrowPointerDown"
373
+ >
200
374
  <FdsIcon name="arrowDown" :size="24" :class="arrowClasses" />
201
- </div>
375
+ </button>
202
376
  </div>
203
-
204
377
  <ul
205
- v-if="dropdownOpen && !disabled"
206
- ref="listRef"
378
+ v-if="dropdownOpen && !disabled && filteredCountries.length > 0"
207
379
  role="listbox"
208
- class="absolute left-0 z-20 mt-1 min-w-[20rem] w-max max-w-[calc(100vw-2rem)] max-h-[280px] overflow-y-auto rounded-md border border-gray-200 bg-white shadow-lg"
380
+ :id="listboxId"
381
+ class="absolute left-0 z-20 mt-1 min-w-[20rem] w-max max-w-[calc(100vw-2rem)] max-h-[280px] overflow-y-auto rounded-md border border-gray-300 bg-white"
209
382
  v-bind="listDataAttrs"
383
+ @mouseleave="clearHoverPreview"
210
384
  >
211
- <li v-if="filteredCountries.length === 0" class="px-3 text-sm text-gray-700">
212
- {{ noResultsLabel }}
213
- </li>
214
385
  <li
215
- v-for="option in filteredCountries"
386
+ v-for="(option, index) in filteredCountries"
216
387
  :key="option.value"
388
+ :id="`fds-phone-country-option-${index}`"
217
389
  role="option"
218
- :id="`fds-country-${option.value}`"
219
390
  :aria-selected="option.value === country"
220
- class="mb-0 border-b border-gray-100"
391
+ class="mb-0 border-b border-gray-100 hover:bg-blue_t-100 active:bg-blue_t-200 last:border-b-0!"
392
+ :class="{
393
+ 'border-gray-300': index === lastPrioritizedFilteredIndex,
394
+ 'bg-blue_t-100': activeIndex === index,
395
+ }"
221
396
  >
222
397
  <button
223
398
  type="button"
224
- class="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-blue_t-100 focus:bg-gray-100 focus:outline-none"
225
- @mousedown.prevent="selectCountry(option)"
399
+ tabindex="-1"
400
+ class="flex w-full items-center justify-between gap-2 py-4 px-3 text-left cursor-pointer"
401
+ @mouseenter="onOptionMouseEnter(option)"
402
+ @mouseleave="onOptionMouseLeave"
403
+ @mousedown.prevent
404
+ @click="selectCountry(option)"
226
405
  >
227
- <span class="shrink-0 text-xl leading-none" aria-hidden="true">{{ countryCodeToFlag(option.value) }}</span>
228
- <span class="min-w-0 flex-1 font-bold tabular-nums text-gray-900 max-w-12">
229
- {{ t('common.plus') }}{{ option.countryCode }}
406
+ <span class="flex items-center gap-2">
407
+ <span class="flex items-center gap-3">
408
+ <span class="shrink-0 text-xl leading-none" aria-hidden="true">
409
+ {{ countryCodeToFlag(option.value) }}
410
+ </span>
411
+ {{ option.countryName }}
412
+ </span>
413
+ <span class="text-gray-600">({{ t('common.plus') }}{{ option.countryCode }})</span>
230
414
  </span>
231
- <span class="min-w-0 flex-2 text-gray-900 truncate text-ellipsis">{{ option.countryName }}</span>
232
- <span class="flex w-5 shrink-0 items-center justify-center" aria-hidden="true">
233
- <FdsIcon v-if="option.value === country" name="check" :size="20" class="fill-green-600" />
415
+ <span aria-hidden="true">
416
+ <FdsIcon v-if="option.value === country" name="check" :size="24" class="fill-green-600" />
234
417
  </span>
235
418
  </button>
236
419
  </li>
@@ -36,20 +36,36 @@ function getLocalizedCountryName(iso2: string, locale: string): string {
36
36
  return displayNames?.of(iso2) ?? iso2
37
37
  }
38
38
 
39
+ export const PRIORITIZED_COUNTRIES = ['SE', 'NO', 'DK', 'FI'] as const
40
+
41
+ export function isPrioritizedCountry(iso2: string): boolean {
42
+ return PRIORITIZED_COUNTRIES.includes(iso2 as (typeof PRIORITIZED_COUNTRIES)[number])
43
+ }
44
+
39
45
  export function sortCountryOptionsByName(
40
46
  options: CountryPhoneOption[],
41
47
  locale = 'sv-SE',
48
+ _preferredCountry = 'SE',
42
49
  ): CountryPhoneOption[] {
43
50
  const sortLocale = resolveDisplayLocale(locale).split(/[-_]/)[0] || 'sv'
44
51
  return [...options].sort((a, b) => {
52
+ const priorityA = PRIORITIZED_COUNTRIES.indexOf(a.value as (typeof PRIORITIZED_COUNTRIES)[number])
53
+ const priorityB = PRIORITIZED_COUNTRIES.indexOf(b.value as (typeof PRIORITIZED_COUNTRIES)[number])
54
+
55
+ if (priorityA !== -1 || priorityB !== -1) {
56
+ if (priorityA === -1) return 1
57
+ if (priorityB === -1) return -1
58
+ if (priorityA !== priorityB) return priorityA - priorityB
59
+ }
60
+
45
61
  const nameA = a.countryName || a.label
46
62
  const nameB = b.countryName || b.label
47
63
  return nameA.localeCompare(nameB, sortLocale)
48
64
  })
49
65
  }
50
66
 
51
- /** Country options for phone fields, sorted alphabetically by localized country name. */
52
- export function buildCountryOptions(locale = 'sv-SE'): CountryPhoneOption[] {
67
+ /** Country options for phone fields; SE/NO/DK/FI first, then alphabetical by name. */
68
+ export function buildCountryOptions(locale = 'sv-SE', preferredCountry = 'SE'): CountryPhoneOption[] {
53
69
  const options: CountryPhoneOption[] = getCountries().map((iso2) => {
54
70
  const countryName = getLocalizedCountryName(iso2, locale)
55
71
  const countryCode = getCountryCallingCode(iso2)
@@ -61,7 +77,7 @@ export function buildCountryOptions(locale = 'sv-SE'): CountryPhoneOption[] {
61
77
  }
62
78
  })
63
79
 
64
- return sortCountryOptionsByName(options, locale)
80
+ return sortCountryOptionsByName(options, locale, preferredCountry)
65
81
  }
66
82
 
67
83
  /** ISO 3166-1 alpha-2 to regional indicator flag emoji, e.g. `SE` β†’ πŸ‡ΈπŸ‡ͺ */
@@ -34,5 +34,6 @@ export interface FdsPhonenumberEmits {
34
34
  'update:country': [country: string]
35
35
  'update:e164': [phoneNumber: string]
36
36
  valid: [value: boolean | null]
37
+ noCountryResults: [value: boolean]
37
38
  blur: [ev: FocusEvent]
38
39
  }