fds-vue-core 8.2.2 → 8.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fds-vue-core",
3
- "version": "8.2.2",
3
+ "version": "8.2.4",
4
4
  "description": "FDS Vue Core Component Library",
5
5
  "type": "module",
6
6
  "main": "./dist/fds-vue-core.cjs.js",
@@ -63,7 +63,7 @@ const meta: Meta<typeof FdsInput> = {
63
63
  },
64
64
  maskOptions: {
65
65
  control: { type: 'object' },
66
- description: 'Additional options for IMask (e.g., { lazy: true, placeholderChar: "" })',
66
+ description: 'Additional IMask options (lazy defaults to true).',
67
67
  },
68
68
  maxlength: {
69
69
  control: { type: 'number' },
@@ -302,6 +302,26 @@ export const WithMaskPhone: Story = {
302
302
  }),
303
303
  }
304
304
 
305
+ export const WithMaskPostalCode: Story = {
306
+ render: () => ({
307
+ components: { FdsInput },
308
+ setup() {
309
+ const postalCode = ref('')
310
+ return { postalCode }
311
+ },
312
+ template: `
313
+ <div>
314
+ <FdsInput
315
+ v-model="postalCode"
316
+ label="Postnummer"
317
+ mask="000 00"
318
+ />
319
+ <p class="mt-4">Värde: {{ postalCode }}</p>
320
+ </div>
321
+ `,
322
+ }),
323
+ }
324
+
305
325
  export const AllStates: Story = {
306
326
  render: () => ({
307
327
  components: { FdsInput },
@@ -38,7 +38,7 @@ const props = withDefaults(defineProps<FdsInputProps>(), {
38
38
 
39
39
  const emit = defineEmits<{
40
40
  (e: 'input', ev: Event): void
41
- (e: 'change', ev: Event): void
41
+ (e: 'autocomplete', ev: Event): void
42
42
  (e: 'clearInput'): void
43
43
  (e: 'update:value', value: string): void
44
44
  (e: 'keyup', ev: KeyboardEvent): void
@@ -49,8 +49,12 @@ const emit = defineEmits<{
49
49
  const { id, type, maxlength, attrs } = useAttrsWithDefaults(props)
50
50
  const { t } = useFdsI18n()
51
51
 
52
- // Get type from attrs first (user-provided), then props, with 'text' as fallback
53
- const inputType = computed(() => type.value ?? props.type ?? 'text')
52
+ // Get type from attrs first (user-provided), then props, with 'text' as fallback.
53
+ // IMask only supports type="text".
54
+ const inputType = computed(() => {
55
+ if (props.mask) return 'text'
56
+ return type.value ?? props.type ?? 'text'
57
+ })
54
58
 
55
59
  const autoId = `fds-input-${Math.random().toString(36).slice(2, 9)}`
56
60
  const inputId = computed(() => id.value ?? autoId)
@@ -138,28 +142,29 @@ const inputPaddingRight = computed(() => {
138
142
 
139
143
  const externalClass = computed(() => (props.class ?? attrs.class) as HTMLAttributes['class'] | undefined)
140
144
 
145
+ const inputPaddingClasses = 'px-3 py-[calc(0.75rem-1px)]'
146
+
141
147
  const inputClasses = computed(() => [
142
- 'block rounded-md border border-gray-500 px-3 py-[calc(0.75rem-1px)]',
148
+ 'block rounded-md border border-gray-500',
143
149
  maxlength.value ? '' : 'w-full',
150
+ inputPaddingClasses,
144
151
  inputPaddingRight.value,
145
152
  'focus:outline-2 focus:outline-blue-500 -outline-offset-2 focus:border-transparent',
146
153
  showDateIcon.value &&
147
154
  '[&::-webkit-calendar-picker-indicator]:opacity-0 [&::-webkit-calendar-picker-indicator]:pointer-events-none',
148
155
  props.disabled
149
156
  ? 'text-gray-800 outline-dashed outline-2 outline-gray-400 cursor-not-allowed border-transparent'
150
- : 'bg-white',
157
+ : 'text-gray-900 bg-white',
151
158
  isInvalid.value && 'outline-2 outline-red-600',
152
159
  props.inputClass,
153
160
  ])
154
161
 
155
162
  const inputStyle = computed(() => {
156
- if (maxlength.value) {
157
- return {
158
- width: `calc(${maxlength.value}ch + 1.5rem + 0.25rem)`,
159
- maxWidth: '100%',
160
- }
163
+ if (!maxlength.value) return undefined
164
+ return {
165
+ width: `calc(${maxlength.value}ch + 1.5rem + 0.25rem)`,
166
+ maxWidth: '100%',
161
167
  }
162
- return {}
163
168
  })
164
169
 
165
170
  const rightIconsContainerClasses = computed(() => [
@@ -167,6 +172,11 @@ const rightIconsContainerClasses = computed(() => [
167
172
  showClearButton.value ? 'right-2' : 'right-3',
168
173
  ])
169
174
 
175
+ const inputBindings = computed(() => ({
176
+ ...inputAttrs.value,
177
+ ...(props.mask ? {} : { value: internalValue.value }),
178
+ }))
179
+
170
180
  // Use modelValue if bound via v-model, otherwise use value prop
171
181
  const internalValue = computed({
172
182
  get: () => (modelValue.value !== undefined ? modelValue.value : (props.value ?? '')),
@@ -180,6 +190,9 @@ const internalValue = computed({
180
190
 
181
191
  function onClear() {
182
192
  internalValue.value = ''
193
+ if (maskInstance) {
194
+ maskInstance.value = ''
195
+ }
183
196
  emit('clearInput')
184
197
  }
185
198
 
@@ -221,27 +234,22 @@ const inputRef = ref<HTMLInputElement | null>(null)
221
234
  let maskInstance: any = null
222
235
 
223
236
  const createMask = () => {
224
- // Destroy existing mask if any
225
237
  if (maskInstance) {
226
238
  maskInstance.destroy()
227
239
  maskInstance = null
228
240
  }
229
241
 
230
- // Create new mask if mask prop is provided and input exists
231
242
  if (props.mask && inputRef.value) {
232
- const maskConfig = {
243
+ maskInstance = IMask(inputRef.value, {
233
244
  mask: props.mask,
234
245
  ...props.maskOptions,
235
- }
246
+ lazy: props.maskOptions?.lazy ?? true,
247
+ } as any)
236
248
 
237
- maskInstance = IMask(inputRef.value, maskConfig as any)
238
-
239
- // Sync mask value with internalValue
240
249
  if (internalValue.value) {
241
250
  maskInstance.value = internalValue.value
242
251
  }
243
252
 
244
- // Update internalValue when mask value changes
245
253
  maskInstance.on('accept', () => {
246
254
  if (maskInstance) {
247
255
  internalValue.value = maskInstance.value
@@ -279,24 +287,35 @@ const ariaDescribedBy = computed(() => {
279
287
  return props.meta ? metaId.value : undefined
280
288
  })
281
289
 
282
- // Watch for mask prop changes and recreate mask
290
+ // Watch for mask-related prop changes and recreate mask
283
291
  watch(
284
- () => props.mask,
292
+ () => [props.mask, props.maskOptions] as const,
285
293
  () => {
286
294
  nextTick(() => {
287
295
  createMask()
288
296
  })
289
297
  },
298
+ { deep: true },
290
299
  )
291
300
 
301
+ function shouldSyncMaskFromModel(newValue: string | undefined, currentMaskedValue: string): boolean {
302
+ if (newValue === undefined) {
303
+ return false
304
+ }
305
+ if (document.activeElement !== inputRef.value) {
306
+ return true
307
+ }
308
+ const newDigits = String(newValue).replace(/\D/g, '')
309
+ const currentDigits = String(currentMaskedValue ?? '').replace(/\D/g, '')
310
+ return newDigits !== currentDigits
311
+ }
312
+
292
313
  // Watch for external value changes and update mask
293
- // Only update if the value actually changed and user is not actively editing
294
314
  watch(
295
315
  () => props.value,
296
316
  (newValue) => {
297
317
  if (maskInstance && newValue !== undefined && newValue !== maskInstance.value) {
298
- // Only update if input doesn't have focus (user is not editing)
299
- if (document.activeElement !== inputRef.value) {
318
+ if (shouldSyncMaskFromModel(newValue, maskInstance.value)) {
300
319
  maskInstance.value = newValue
301
320
  }
302
321
  }
@@ -307,8 +326,7 @@ watch(
307
326
  () => modelValue.value,
308
327
  (newValue) => {
309
328
  if (maskInstance && newValue !== undefined && newValue !== maskInstance.value) {
310
- // Only update if input doesn't have focus (user is not editing)
311
- if (document.activeElement !== inputRef.value) {
329
+ if (shouldSyncMaskFromModel(newValue, maskInstance.value)) {
312
330
  maskInstance.value = newValue
313
331
  }
314
332
  }
@@ -340,7 +358,6 @@ watch(
340
358
  ref="inputRef"
341
359
  :type="inputType === 'password' ? (showPassword ? 'text' : 'password') : inputType"
342
360
  :required="props.required"
343
- :value="internalValue"
344
361
  :disabled="props.disabled"
345
362
  :tabindex="props.disabled ? -1 : undefined"
346
363
  :aria-invalid="props.valid === false ? 'true' : undefined"
@@ -353,9 +370,10 @@ watch(
353
370
  :placeholder="props.placeholder"
354
371
  :pattern="props.pattern"
355
372
  :searchIcon="props.searchIcon"
356
- v-bind="inputAttrs"
373
+ v-bind="inputBindings"
357
374
  @input="handleInputChange"
358
375
  @change="handleInputChange"
376
+ @autocomplete="$emit('autocomplete', $event)"
359
377
  @blur="$emit('blur', $event)"
360
378
  @keyup="
361
379
  (ev) => {
@@ -3,6 +3,8 @@ import { computed, ref, useAttrs, watch } from 'vue'
3
3
  import { useFdsI18n } from '../../../plugin/useFdsI18n'
4
4
  import FdsInput from '../FdsInput/FdsInput.vue'
5
5
  import { buildCountryOptions, sortCountryOptionsByName } from './countries'
6
+ import { getPhoneMaskForCountry } from './phoneMask'
7
+ import { normalizeNationalPhoneInput, shouldNormalizePhoneInput } from './normalizePhoneInput'
6
8
  import FdsPhonenumberCountryPicker from './FdsPhonenumberCountryPicker.vue'
7
9
  import type { FdsPhonenumberEmits, FdsPhonenumberProps } from './types'
8
10
  import { getPhoneValidationState, validatePhoneNumber } from './validatePhone'
@@ -11,7 +13,7 @@ defineOptions({
11
13
  inheritAttrs: false,
12
14
  })
13
15
 
14
- const [nationalNumber, modelModifiers] = defineModel<string>({ default: '' })
16
+ const [nationalNumber] = defineModel<string>({ default: '' })
15
17
  const country = defineModel<string>('country', { default: 'SE' })
16
18
  const attrs = useAttrs()
17
19
 
@@ -25,12 +27,9 @@ const props = withDefaults(defineProps<FdsPhonenumberProps>(), {
25
27
  autocomplete: 'tel',
26
28
  required: false,
27
29
  placeholder: undefined,
28
- maxlength: undefined,
29
- minlength: undefined,
30
30
  name: undefined,
31
31
  autofocus: false,
32
32
  readonly: false,
33
- pattern: undefined,
34
33
  defaultCountry: 'SE',
35
34
  countries: undefined,
36
35
  locale: undefined,
@@ -57,12 +56,10 @@ const forwardedInputProps = computed(() => ({
57
56
  autocomplete: props.autocomplete,
58
57
  required: props.required,
59
58
  placeholder: props.placeholder,
60
- maxlength: props.maxlength,
61
- minlength: props.minlength,
62
59
  name: props.name,
63
60
  autofocus: props.autofocus,
64
61
  readonly: props.readonly,
65
- pattern: props.pattern,
62
+ inputmode: 'tel',
66
63
  ...inputAttrs.value,
67
64
  }))
68
65
 
@@ -77,6 +74,12 @@ const countryItems = computed(() => {
77
74
  )
78
75
  })
79
76
 
77
+ const phoneMask = computed(() => getPhoneMaskForCountry(country.value ?? props.defaultCountry))
78
+
79
+ const phoneMaskOptions = computed(() => ({
80
+ prepare: (value: string) => applyPhoneNormalization(value, country.value ?? props.defaultCountry),
81
+ }))
82
+
80
83
  /** Set after the phone number field has blurred; validation does not run on input. */
81
84
  const committedValid = ref<boolean | null>(null)
82
85
 
@@ -109,6 +112,20 @@ function runValidation() {
109
112
  emit('update:e164', result.isValid && result.phoneNumber ? result.phoneNumber : '')
110
113
  }
111
114
 
115
+ function applyPhoneNormalization(value: string, countryCode = country.value ?? props.defaultCountry): string {
116
+ if (!shouldNormalizePhoneInput(value, countryCode)) {
117
+ return value
118
+ }
119
+ return normalizeNationalPhoneInput(value, countryCode)
120
+ }
121
+
122
+ watch(nationalNumber, (value) => {
123
+ const normalized = applyPhoneNormalization(value ?? '')
124
+ if (normalized !== (value ?? '')) {
125
+ nationalNumber.value = normalized
126
+ }
127
+ })
128
+
112
129
  watch(country, () => {
113
130
  if (committedValid.value === null) {
114
131
  return
@@ -117,6 +134,10 @@ watch(country, () => {
117
134
  })
118
135
 
119
136
  function handleBlur(ev: FocusEvent) {
137
+ const normalized = applyPhoneNormalization(nationalNumber.value ?? '')
138
+ if (normalized !== (nationalNumber.value ?? '')) {
139
+ nationalNumber.value = normalized
140
+ }
120
141
  runValidation()
121
142
  emit('blur', ev)
122
143
  }
@@ -139,7 +160,7 @@ function onNoCountryResults(value: boolean) {
139
160
  <div v-if="meta" class="font-thin mb-1">
140
161
  {{ meta }}
141
162
  </div>
142
- <div class="flex flex-wrap items-start gap-x-1">
163
+ <div class="flex flex-wrap items-start gap-x-2">
143
164
  <FdsPhonenumberCountryPicker
144
165
  v-model="country"
145
166
  :items="countryItems"
@@ -152,8 +173,8 @@ function onNoCountryResults(value: boolean) {
152
173
  />
153
174
  <FdsInput
154
175
  v-model="nationalNumber"
155
- :modelModifiers="modelModifiers"
156
- type="tel"
176
+ :mask="phoneMask"
177
+ :mask-options="phoneMaskOptions"
157
178
  :valid="displayValid"
158
179
  :disabled="disabled"
159
180
  :optional="optional"
@@ -377,7 +377,7 @@ onBeforeUnmount(() => {
377
377
  v-if="dropdownOpen && !disabled && filteredCountries.length > 0"
378
378
  role="listbox"
379
379
  :id="listboxId"
380
- 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"
380
+ class="absolute left-0 z-20 mt-2 min-w-[20rem] w-max max-w-[calc(100vw-2rem)] max-h-[280px] overflow-y-auto rounded-md border border-gray-300 bg-white"
381
381
  v-bind="listDataAttrs"
382
382
  @mouseleave="clearHoverPreview"
383
383
  >
@@ -0,0 +1,88 @@
1
+ import parsePhoneNumberFromString, { getCountryCallingCode, type CountryCode } from 'libphonenumber-js/mobile/es6'
2
+ import { getPhoneMaskForCountry } from './phoneMask'
3
+
4
+ const nationalDigitsForMask = (parsed: NonNullable<ReturnType<typeof parsePhoneNumberFromString>>): string =>
5
+ parsed.formatNational().replace(/\D/g, '')
6
+
7
+ const tryParseForCountry = (candidate: string, country: CountryCode): ReturnType<typeof parsePhoneNumberFromString> => {
8
+ const parsed = parsePhoneNumberFromString(candidate, country) ?? parsePhoneNumberFromString(candidate)
9
+ if (!parsed?.isValid() || parsed.country !== country) {
10
+ return undefined
11
+ }
12
+ return parsed
13
+ }
14
+
15
+ /** Whether the value likely contains a country calling code or international formatting. */
16
+ export const shouldNormalizePhoneInput = (value: string, countryIso2: string): boolean => {
17
+ const trimmed = value.trim()
18
+ if (!trimmed) {
19
+ return false
20
+ }
21
+ if (trimmed.includes('+') || trimmed.startsWith('00')) {
22
+ return true
23
+ }
24
+
25
+ const digits = trimmed.replace(/\D/g, '')
26
+ const mask = getPhoneMaskForCountry(countryIso2)
27
+ if (!mask) {
28
+ return false
29
+ }
30
+
31
+ const maxDigits = (mask.match(/0/g) ?? []).length
32
+ if (digits.length > maxDigits) {
33
+ return true
34
+ }
35
+
36
+ try {
37
+ const callingCode = getCountryCallingCode(countryIso2 as CountryCode)
38
+ if (digits.startsWith(callingCode) && digits.length > maxDigits - 1) {
39
+ return true
40
+ }
41
+ } catch {
42
+ return false
43
+ }
44
+
45
+ return false
46
+ }
47
+
48
+ /**
49
+ * Converts international/autocomplete values to national digits for the IMask field.
50
+ * IMask reapplies spacing from the country mask.
51
+ */
52
+ export const normalizeNationalPhoneInput = (value: string, countryIso2: string): string => {
53
+ const trimmed = value.trim()
54
+ if (!trimmed) {
55
+ return ''
56
+ }
57
+
58
+ const country = countryIso2.trim().toUpperCase() as CountryCode
59
+ const digits = trimmed.replace(/\D/g, '')
60
+
61
+ const candidates = new Set<string>([trimmed])
62
+ if (digits) {
63
+ candidates.add(digits)
64
+ candidates.add(`+${digits}`)
65
+ }
66
+
67
+ for (const candidate of candidates) {
68
+ const parsed = tryParseForCountry(candidate, country)
69
+ if (parsed) {
70
+ return nationalDigitsForMask(parsed)
71
+ }
72
+ }
73
+
74
+ try {
75
+ const callingCode = getCountryCallingCode(country)
76
+ if (digits.startsWith(callingCode) && digits.length > callingCode.length) {
77
+ const withoutCode = digits.slice(callingCode.length)
78
+ const parsed = tryParseForCountry(withoutCode, country) ?? tryParseForCountry(`0${withoutCode}`, country)
79
+ if (parsed) {
80
+ return nationalDigitsForMask(parsed)
81
+ }
82
+ }
83
+ } catch {
84
+ return trimmed
85
+ }
86
+
87
+ return trimmed
88
+ }
@@ -0,0 +1,28 @@
1
+ import { getExampleNumber, type CountryCode } from 'libphonenumber-js/mobile/es6'
2
+ import examples from 'libphonenumber-js/mobile/examples'
3
+
4
+ const maskCache = new Map<string, string>()
5
+
6
+ /** IMask pattern with space grouping derived from libphonenumber mobile example numbers. */
7
+ export const getPhoneMaskForCountry = (countryIso2: string): string | undefined => {
8
+ const country = countryIso2.trim().toUpperCase() as CountryCode
9
+ const cached = maskCache.get(country)
10
+ if (cached) {
11
+ return cached
12
+ }
13
+
14
+ const example = getExampleNumber(country, examples)
15
+ if (!example) {
16
+ return undefined
17
+ }
18
+
19
+ const groups = example
20
+ .formatNational()
21
+ .split(/\D+/)
22
+ .filter(Boolean)
23
+ .map((part) => part.length)
24
+
25
+ const mask = groups.map((size) => '0'.repeat(size)).join(' ')
26
+ maskCache.set(country, mask)
27
+ return mask
28
+ }
@@ -7,12 +7,9 @@ type FdsPhonenumberInputPassthroughProps = Pick<
7
7
  | 'autocomplete'
8
8
  | 'required'
9
9
  | 'placeholder'
10
- | 'maxlength'
11
- | 'minlength'
12
10
  | 'name'
13
11
  | 'autofocus'
14
12
  | 'readonly'
15
- | 'pattern'
16
13
  >
17
14
 
18
15
  export interface FdsPhonenumberProps extends FdsPhonenumberInputPassthroughProps {