fds-vue-core 7.2.1 β†’ 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fds-vue-core",
3
- "version": "7.2.1",
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,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,108 @@ 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)
45
67
 
46
- const inputDisplayValue = computed(() => {
47
- if (dropdownOpen.value || isFocused.value) {
48
- return searchQuery.value
68
+ const activeCountry = computed(() => {
69
+ if (activeIndex.value < 0) {
70
+ return null
49
71
  }
50
- if (!selectedCountry.value) {
51
- return ''
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
+ )
82
+
83
+ const showDialPreviewPadding = computed(
84
+ () => showSelectedDialPreview.value || showHoverDialPreview.value || showActiveDialPreview.value,
85
+ )
86
+
87
+ const dialPreviewCountry = computed(() => {
88
+ if (showHoverDialPreview.value && hoveredCountry.value) {
89
+ return hoveredCountry.value
90
+ }
91
+ if (showActiveDialPreview.value && activeCountry.value) {
92
+ return activeCountry.value
52
93
  }
53
- return `+${selectedCountry.value.countryCode}`
94
+ if (showSelectedDialPreview.value && selectedCountry.value) {
95
+ return selectedCountry.value
96
+ }
97
+ return null
54
98
  })
55
99
 
100
+ const activeDescendantId = computed(() =>
101
+ activeIndex.value >= 0 ? `fds-phone-country-option-${activeIndex.value}` : undefined,
102
+ )
103
+
56
104
  const inputDataAttrs = computed(() => (props.dataTestid ? { 'data-testid': props.dataTestid } : {}))
57
105
 
58
106
  const listDataAttrs = computed(() => (props.dataTestid ? { 'data-testid': `${props.dataTestid}-list` } : {}))
59
107
 
108
+ const dialPreviewClasses = computed(() => [
109
+ 'pointer-events-none absolute inset-0 flex items-center pl-10 pr-10 text-base leading-6 tabular-nums',
110
+ showHoverDialPreview.value ? 'text-gray-900/50' : 'text-gray-900',
111
+ ])
112
+
60
113
  const inputClasses = computed(() => [
61
114
  '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',
115
+ showDialPreviewPadding.value ? 'pl-10 pr-10' : 'px-3 pr-10',
63
116
  'focus:outline-2 focus:outline-blue-500 -outline-offset-2 focus:border-transparent',
64
117
  props.disabled
65
118
  ? 'text-gray-800 outline-dashed outline-2 outline-gray-400 cursor-not-allowed border-transparent bg-gray-50'
66
- : 'bg-white text-gray-900',
119
+ : 'cursor-text bg-white text-gray-900',
67
120
  isInvalid.value && 'outline-2 -outline-offset-2 outline-red-600',
68
121
  ])
69
122
 
123
+ const arrowButtonClasses = computed(() => [
124
+ 'absolute right-0 top-0 z-10 flex h-12 w-10 items-center justify-center border-0 bg-transparent p-0',
125
+ props.disabled ? 'cursor-not-allowed' : 'cursor-pointer',
126
+ ])
127
+
70
128
  const arrowClasses = computed(() => [
71
- 'transition-transform duration-200 ease-in-out',
129
+ 'pointer-events-none transition-transform duration-200 ease-in-out',
72
130
  {
73
131
  'fill-gray-500': props.disabled,
74
132
  'fill-red-600': isInvalid.value && !props.disabled,
@@ -77,48 +135,76 @@ const arrowClasses = computed(() => [
77
135
  },
78
136
  ])
79
137
 
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) {
138
+ const openDropdown = () => {
139
+ if (props.disabled) {
91
140
  return
92
141
  }
142
+ dropdownOpen.value = true
143
+ isFocused.value = true
144
+ searchQuery.value = ''
145
+ activeIndex.value = -1
146
+ }
93
147
 
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
148
+ const clearHoverPreview = () => {
149
+ hoveredCountry.value = null
99
150
  }
100
151
 
101
- async function openDropdown() {
102
- if (props.disabled) {
152
+ const clearActiveOption = () => {
153
+ activeIndex.value = -1
154
+ }
155
+
156
+ const setActiveIndex = (index: number) => {
157
+ if (!filteredCountries.value.length) {
158
+ clearActiveOption()
103
159
  return
104
160
  }
105
- dropdownOpen.value = true
106
- isFocused.value = true
107
- searchQuery.value = ''
108
- await scrollToSelectedCountry()
161
+ activeIndex.value = Math.max(0, Math.min(index, filteredCountries.value.length - 1))
109
162
  }
110
163
 
111
- function closeDropdown() {
164
+ const setActiveIndexToSelectedOrFirst = () => {
165
+ const selectedIndex = filteredCountries.value.findIndex((option) => option.value === country.value)
166
+ setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0)
167
+ }
168
+
169
+ const setActiveIndexToSelectedOrLast = () => {
170
+ const selectedIndex = filteredCountries.value.findIndex((option) => option.value === country.value)
171
+ setActiveIndex(selectedIndex >= 0 ? selectedIndex : filteredCountries.value.length - 1)
172
+ }
173
+
174
+ const onOptionMouseEnter = (option: CountryPhoneOption) => {
175
+ hoveredCountry.value = option
176
+ }
177
+
178
+ const onOptionMouseLeave = () => {
179
+ clearHoverPreview()
180
+ }
181
+
182
+ const closeDropdown = () => {
112
183
  dropdownOpen.value = false
113
184
  isFocused.value = false
114
185
  searchQuery.value = ''
186
+ clearHoverPreview()
187
+ clearActiveOption()
115
188
  }
116
189
 
117
- function onInputFocus() {
190
+ const onInputFocus = () => {
191
+ openDropdown()
192
+ }
193
+
194
+ const onArrowPointerDown = () => {
195
+ if (props.disabled) {
196
+ return
197
+ }
198
+ if (dropdownOpen.value) {
199
+ closeDropdown()
200
+ inputRef.value?.blur()
201
+ return
202
+ }
118
203
  openDropdown()
204
+ inputRef.value?.focus()
119
205
  }
120
206
 
121
- function onInputBlur() {
207
+ const onInputBlur = () => {
122
208
  isFocused.value = false
123
209
  window.setTimeout(() => {
124
210
  if (!dropdownOpen.value) {
@@ -128,21 +214,86 @@ function onInputBlur() {
128
214
  }, 120)
129
215
  }
130
216
 
131
- function onSearchInput(event: Event) {
217
+ const onSearchInput = (event: Event) => {
132
218
  const target = event.target as HTMLInputElement
133
219
  searchQuery.value = target.value
220
+ clearActiveOption()
134
221
  if (!dropdownOpen.value) {
135
222
  dropdownOpen.value = true
136
223
  }
137
224
  }
138
225
 
139
- function selectCountry(option: CountryPhoneOption) {
226
+ const onInputKeyDown = (event: KeyboardEvent) => {
227
+ if (props.disabled) {
228
+ return
229
+ }
230
+
231
+ if (event.key === 'ArrowDown' && filteredCountries.value.length > 0) {
232
+ event.preventDefault()
233
+ clearHoverPreview()
234
+ if (!dropdownOpen.value) {
235
+ openDropdown()
236
+ setActiveIndexToSelectedOrFirst()
237
+ return
238
+ }
239
+ if (activeIndex.value < 0) {
240
+ setActiveIndexToSelectedOrFirst()
241
+ return
242
+ }
243
+ setActiveIndex(activeIndex.value + 1)
244
+ return
245
+ }
246
+
247
+ if (event.key === 'ArrowUp' && filteredCountries.value.length > 0) {
248
+ event.preventDefault()
249
+ clearHoverPreview()
250
+ if (!dropdownOpen.value) {
251
+ openDropdown()
252
+ setActiveIndexToSelectedOrLast()
253
+ return
254
+ }
255
+ if (activeIndex.value < 0) {
256
+ setActiveIndexToSelectedOrLast()
257
+ return
258
+ }
259
+ setActiveIndex(activeIndex.value - 1)
260
+ return
261
+ }
262
+
263
+ if (event.key === 'Enter') {
264
+ if (!dropdownOpen.value && filteredCountries.value.length > 0) {
265
+ event.preventDefault()
266
+ openDropdown()
267
+ setActiveIndexToSelectedOrFirst()
268
+ return
269
+ }
270
+
271
+ if (dropdownOpen.value && activeCountry.value) {
272
+ event.preventDefault()
273
+ selectCountry(activeCountry.value)
274
+ }
275
+ return
276
+ }
277
+
278
+ if (event.key === 'Escape' && dropdownOpen.value) {
279
+ event.preventDefault()
280
+ closeDropdown()
281
+ inputRef.value?.focus()
282
+ return
283
+ }
284
+
285
+ if (event.key === 'Tab' && dropdownOpen.value) {
286
+ closeDropdown()
287
+ }
288
+ }
289
+
290
+ const selectCountry = (option: CountryPhoneOption) => {
140
291
  country.value = option.value
141
292
  closeDropdown()
142
293
  inputRef.value?.blur()
143
294
  }
144
295
 
145
- function onClickOutside(event: MouseEvent) {
296
+ const onClickOutside = (event: MouseEvent) => {
146
297
  if (!dropdownOpen.value || !rootRef.value) {
147
298
  return
148
299
  }
@@ -160,6 +311,24 @@ watch(
160
311
  },
161
312
  )
162
313
 
314
+ watch(filteredCountries, (options) => {
315
+ if (!options.length) {
316
+ clearActiveOption()
317
+ return
318
+ }
319
+ if (activeIndex.value >= options.length) {
320
+ setActiveIndex(options.length - 1)
321
+ }
322
+ })
323
+
324
+ watch(
325
+ hasNoCountryResults,
326
+ (value) => {
327
+ emit('noCountryResults', value)
328
+ },
329
+ { immediate: true },
330
+ )
331
+
163
332
  onMounted(() => {
164
333
  window.addEventListener('mousedown', onClickOutside)
165
334
  })
@@ -172,13 +341,12 @@ onBeforeUnmount(() => {
172
341
  <template>
173
342
  <div ref="rootRef" class="relative w-32" :class="props.class">
174
343
  <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>
344
+ <div v-if="dialPreviewCountry" :class="dialPreviewClasses" aria-hidden="true">
345
+ <span class="absolute left-3 top-1/2 -translate-y-1/2 text-xl leading-none">
346
+ {{ countryCodeToFlag(dialPreviewCountry.value) }}
347
+ </span>
348
+ {{ t('common.plus') }}{{ dialPreviewCountry.countryCode }}
349
+ </div>
182
350
  <input
183
351
  ref="inputRef"
184
352
  type="search"
@@ -186,51 +354,70 @@ onBeforeUnmount(() => {
186
354
  role="combobox"
187
355
  :aria-expanded="dropdownOpen"
188
356
  aria-autocomplete="list"
357
+ aria-haspopup="listbox"
189
358
  :aria-label="ariaLabel"
190
- :value="inputDisplayValue"
359
+ :aria-controls="dropdownOpen ? listboxId : undefined"
360
+ :aria-activedescendant="activeDescendantId"
361
+ :value="searchQuery"
191
362
  :disabled="disabled"
192
363
  :class="inputClasses"
193
364
  v-bind="inputDataAttrs"
194
365
  @focus="onInputFocus"
195
366
  @blur="onInputBlur"
367
+ @keydown="onInputKeyDown"
196
368
  @input="onSearchInput"
197
- class="border-r-0 rounded-r-none"
198
369
  />
199
- <div class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
370
+ <button
371
+ type="button"
372
+ tabindex="-1"
373
+ :disabled="disabled"
374
+ :class="arrowButtonClasses"
375
+ aria-hidden="true"
376
+ @mousedown.prevent="onArrowPointerDown"
377
+ >
200
378
  <FdsIcon name="arrowDown" :size="24" :class="arrowClasses" />
201
- </div>
379
+ </button>
202
380
  </div>
203
-
204
381
  <ul
205
- v-if="dropdownOpen && !disabled"
206
- ref="listRef"
382
+ v-if="dropdownOpen && !disabled && filteredCountries.length > 0"
207
383
  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"
384
+ :id="listboxId"
385
+ 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
386
  v-bind="listDataAttrs"
387
+ @mouseleave="clearHoverPreview"
210
388
  >
211
- <li v-if="filteredCountries.length === 0" class="px-3 text-sm text-gray-700">
212
- {{ noResultsLabel }}
213
- </li>
214
389
  <li
215
- v-for="option in filteredCountries"
390
+ v-for="(option, index) in filteredCountries"
216
391
  :key="option.value"
392
+ :id="`fds-phone-country-option-${index}`"
217
393
  role="option"
218
- :id="`fds-country-${option.value}`"
219
394
  :aria-selected="option.value === country"
220
- class="mb-0 border-b border-gray-100"
395
+ class="mb-0 border-b border-gray-100 hover:bg-blue_t-100 active:bg-blue_t-200 last:border-b-0!"
396
+ :class="{
397
+ 'border-gray-300': index === lastPrioritizedFilteredIndex,
398
+ 'bg-blue_t-100': activeIndex === index,
399
+ }"
221
400
  >
222
401
  <button
223
402
  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)"
403
+ tabindex="-1"
404
+ class="flex w-full items-center justify-between gap-2 py-4 px-3 text-left cursor-pointer"
405
+ @mouseenter="onOptionMouseEnter(option)"
406
+ @mouseleave="onOptionMouseLeave"
407
+ @mousedown.prevent
408
+ @click="selectCountry(option)"
226
409
  >
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 }}
410
+ <span class="flex items-center gap-2">
411
+ <span class="flex items-center gap-3">
412
+ <span class="shrink-0 text-xl leading-none" aria-hidden="true">
413
+ {{ countryCodeToFlag(option.value) }}
414
+ </span>
415
+ {{ option.countryName }}
416
+ </span>
417
+ <span class="text-gray-600">({{ t('common.plus') }}{{ option.countryCode }})</span>
230
418
  </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" />
419
+ <span aria-hidden="true">
420
+ <FdsIcon v-if="option.value === country" name="check" :size="24" class="fill-green-600" />
234
421
  </span>
235
422
  </button>
236
423
  </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
  }