fds-vue-core 8.2.2 → 8.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": "8.2.2",
3
+ "version": "8.2.3",
4
4
  "description": "FDS Vue Core Component Library",
5
5
  "type": "module",
6
6
  "main": "./dist/fds-vue-core.cjs.js",
@@ -61,9 +61,13 @@ const meta: Meta<typeof FdsInput> = {
61
61
  control: { type: 'text' },
62
62
  description: 'IMask pattern string (e.g., "00000000-0000" for personnummer)',
63
63
  },
64
+ displayChar: {
65
+ control: { type: 'text' },
66
+ description: 'Character shown for unfilled mask positions while typing (e.g. "N" for postnummer)',
67
+ },
64
68
  maskOptions: {
65
69
  control: { type: 'object' },
66
- description: 'Additional options for IMask (e.g., { lazy: true, placeholderChar: "" })',
70
+ description: 'Additional IMask options. Overrides displayChar defaults when set.',
67
71
  },
68
72
  maxlength: {
69
73
  control: { type: 'number' },
@@ -83,6 +87,7 @@ const meta: Meta<typeof FdsInput> = {
83
87
  required: false,
84
88
  type: 'text',
85
89
  mask: undefined,
90
+ displayChar: undefined,
86
91
  maskOptions: undefined,
87
92
  maxlength: undefined,
88
93
  },
@@ -302,6 +307,27 @@ export const WithMaskPhone: Story = {
302
307
  }),
303
308
  }
304
309
 
310
+ export const WithMaskPostalCode: Story = {
311
+ render: () => ({
312
+ components: { FdsInput },
313
+ setup() {
314
+ const postalCode = ref('')
315
+ return { postalCode }
316
+ },
317
+ template: `
318
+ <div>
319
+ <FdsInput
320
+ v-model="postalCode"
321
+ label="Postnummer"
322
+ mask="000 00"
323
+ display-char="N"
324
+ />
325
+ <p class="mt-4">Värde: {{ postalCode }}</p>
326
+ </div>
327
+ `,
328
+ }),
329
+ }
330
+
305
331
  export const AllStates: Story = {
306
332
  render: () => ({
307
333
  components: { FdsInput },
@@ -27,6 +27,7 @@ const props = withDefaults(defineProps<FdsInputProps>(), {
27
27
  meta: undefined,
28
28
  clearButton: false,
29
29
  mask: undefined,
30
+ displayChar: undefined,
30
31
  maskOptions: undefined,
31
32
  class: undefined,
32
33
  pattern: undefined,
@@ -49,8 +50,12 @@ const emit = defineEmits<{
49
50
  const { id, type, maxlength, attrs } = useAttrsWithDefaults(props)
50
51
  const { t } = useFdsI18n()
51
52
 
52
- // Get type from attrs first (user-provided), then props, with 'text' as fallback
53
- const inputType = computed(() => type.value ?? props.type ?? 'text')
53
+ // Get type from attrs first (user-provided), then props, with 'text' as fallback.
54
+ // IMask only supports type="text".
55
+ const inputType = computed(() => {
56
+ if (props.mask) return 'text'
57
+ return type.value ?? props.type ?? 'text'
58
+ })
54
59
 
55
60
  const autoId = `fds-input-${Math.random().toString(36).slice(2, 9)}`
56
61
  const inputId = computed(() => id.value ?? autoId)
@@ -138,35 +143,116 @@ const inputPaddingRight = computed(() => {
138
143
 
139
144
  const externalClass = computed(() => (props.class ?? attrs.class) as HTMLAttributes['class'] | undefined)
140
145
 
141
- const inputClasses = computed(() => [
142
- 'block rounded-md border border-gray-500 px-3 py-[calc(0.75rem-1px)]',
143
- maxlength.value ? '' : 'w-full',
146
+ const maskDisplayValue = ref('')
147
+ const maskPlaceholderChar = ref('')
148
+
149
+ const resolvedPlaceholderChar = computed(() => props.displayChar ?? props.maskOptions?.placeholderChar ?? null)
150
+
151
+ const useMaskPlaceholderStyle = computed(() => {
152
+ if (!props.mask || !resolvedPlaceholderChar.value) return false
153
+ const lazy = props.maskOptions?.lazy ?? (props.displayChar ? false : true)
154
+ return !lazy
155
+ })
156
+
157
+ const maskFieldWrapperClasses = computed(() => {
158
+ if (!useMaskPlaceholderStyle.value) return undefined
159
+ return [
160
+ 'rounded-md border border-gray-500 bg-white bg-clip-padding',
161
+ props.disabled && 'outline-dashed outline-2 outline-gray-400 cursor-not-allowed border-transparent',
162
+ isInvalid.value
163
+ ? 'outline-2 -outline-offset-2 outline-red-600'
164
+ : 'focus-within:outline-2 focus-within:outline-blue-500 focus-within:outline-offset-0',
165
+ 'focus-within:border-transparent',
166
+ ]
167
+ })
168
+
169
+ const maskFieldPaddingClasses = 'px-3 py-[calc(0.75rem-1px)]'
170
+
171
+ const maskOverlayClasses = computed(() => [
172
+ 'pointer-events-none absolute inset-0 z-[3] flex items-center',
173
+ maskFieldPaddingClasses,
144
174
  inputPaddingRight.value,
145
- 'focus:outline-2 focus:outline-blue-500 -outline-offset-2 focus:border-transparent',
146
- showDateIcon.value &&
147
- '[&::-webkit-calendar-picker-indicator]:opacity-0 [&::-webkit-calendar-picker-indicator]:pointer-events-none',
148
- props.disabled
149
- ? 'text-gray-800 outline-dashed outline-2 outline-gray-400 cursor-not-allowed border-transparent'
150
- : 'bg-white',
151
- isInvalid.value && 'outline-2 outline-red-600',
152
- props.inputClass,
153
175
  ])
154
176
 
177
+ const maskDisplayChars = computed(() => {
178
+ const display = maskDisplayValue.value
179
+ const placeholder = maskPlaceholderChar.value || resolvedPlaceholderChar.value
180
+ if (!display || !placeholder) return []
181
+ return [...display].map((char) => ({
182
+ char,
183
+ isPlaceholder: char === placeholder,
184
+ }))
185
+ })
186
+
187
+ function maskCharStyle(isPlaceholder: boolean): { color: string } {
188
+ if (isPlaceholder) {
189
+ return { color: props.disabled ? 'var(--color-gray-500)' : 'var(--color-gray-400)' }
190
+ }
191
+ return { color: props.disabled ? 'var(--color-gray-800)' : 'var(--color-gray-900)' }
192
+ }
193
+
194
+ const inputClasses = computed(() => {
195
+ const shared = [
196
+ 'block rounded-md',
197
+ maxlength.value ? '' : 'w-full',
198
+ inputPaddingRight.value,
199
+ showDateIcon.value &&
200
+ '[&::-webkit-calendar-picker-indicator]:opacity-0 [&::-webkit-calendar-picker-indicator]:pointer-events-none',
201
+ props.inputClass,
202
+ ]
203
+
204
+ if (useMaskPlaceholderStyle.value) {
205
+ return [
206
+ ...shared,
207
+ 'relative z-[2] border-0 bg-transparent shadow-none focus:outline-none',
208
+ maskFieldPaddingClasses,
209
+ 'text-base leading-6 tabular-nums',
210
+ props.disabled ? 'cursor-not-allowed' : '',
211
+ ]
212
+ }
213
+
214
+ return [
215
+ ...shared,
216
+ 'rounded-md border border-gray-500',
217
+ maskFieldPaddingClasses,
218
+ 'focus:outline-2 focus:outline-blue-500 -outline-offset-2 focus:border-transparent',
219
+ props.disabled
220
+ ? 'text-gray-800 outline-dashed outline-2 outline-gray-400 cursor-not-allowed border-transparent'
221
+ : 'text-gray-900 bg-white',
222
+ isInvalid.value && 'outline-2 outline-red-600',
223
+ ]
224
+ })
225
+
155
226
  const inputStyle = computed(() => {
156
- if (maxlength.value) {
157
- return {
158
- width: `calc(${maxlength.value}ch + 1.5rem + 0.25rem)`,
159
- maxWidth: '100%',
160
- }
227
+ const size: Record<string, string> = maxlength.value
228
+ ? {
229
+ width: `calc(${maxlength.value}ch + 1.5rem + 0.25rem)`,
230
+ maxWidth: '100%',
231
+ }
232
+ : {}
233
+
234
+ if (!useMaskPlaceholderStyle.value) {
235
+ return size
236
+ }
237
+
238
+ return {
239
+ ...size,
240
+ color: 'transparent',
241
+ WebkitTextFillColor: 'transparent',
242
+ caretColor: props.disabled ? '#1f2937' : '#111827',
161
243
  }
162
- return {}
163
244
  })
164
245
 
165
246
  const rightIconsContainerClasses = computed(() => [
166
- 'absolute flex gap-2 top-1/2 -translate-y-1/2 items-center justify-end',
247
+ 'absolute z-10 flex gap-2 top-1/2 -translate-y-1/2 items-center justify-end',
167
248
  showClearButton.value ? 'right-2' : 'right-3',
168
249
  ])
169
250
 
251
+ const inputBindings = computed(() => ({
252
+ ...inputAttrs.value,
253
+ ...(props.mask ? {} : { value: internalValue.value }),
254
+ }))
255
+
170
256
  // Use modelValue if bound via v-model, otherwise use value prop
171
257
  const internalValue = computed({
172
258
  get: () => (modelValue.value !== undefined ? modelValue.value : (props.value ?? '')),
@@ -180,6 +266,10 @@ const internalValue = computed({
180
266
 
181
267
  function onClear() {
182
268
  internalValue.value = ''
269
+ if (maskInstance) {
270
+ maskInstance.value = ''
271
+ nextTick(syncMaskDisplay)
272
+ }
183
273
  emit('clearInput')
184
274
  }
185
275
 
@@ -220,6 +310,29 @@ const inputRef = ref<HTMLInputElement | null>(null)
220
310
 
221
311
  let maskInstance: any = null
222
312
 
313
+ function getMaskEmittedValue(instance: typeof maskInstance): string {
314
+ if (!instance) return ''
315
+
316
+ const raw = String(instance.value ?? '')
317
+ if (!useMaskPlaceholderStyle.value) return raw
318
+
319
+ const placeholder = instance.masked?.placeholderChar ?? resolvedPlaceholderChar.value
320
+ if (!placeholder) return raw
321
+
322
+ return raw.split(placeholder).join('').replace(/\s+$/, '')
323
+ }
324
+
325
+ function syncMaskDisplay() {
326
+ if (!inputRef.value) {
327
+ maskDisplayValue.value = ''
328
+ maskPlaceholderChar.value = ''
329
+ return
330
+ }
331
+
332
+ maskPlaceholderChar.value = maskInstance?.masked?.placeholderChar ?? resolvedPlaceholderChar.value ?? ''
333
+ maskDisplayValue.value = maskInstance?.displayValue ?? inputRef.value.value ?? maskInstance?.value ?? ''
334
+ }
335
+
223
336
  const createMask = () => {
224
337
  // Destroy existing mask if any
225
338
  if (maskInstance) {
@@ -232,6 +345,12 @@ const createMask = () => {
232
345
  const maskConfig = {
233
346
  mask: props.mask,
234
347
  ...props.maskOptions,
348
+ ...(props.displayChar
349
+ ? {
350
+ placeholderChar: props.displayChar,
351
+ lazy: props.maskOptions?.lazy ?? false,
352
+ }
353
+ : {}),
235
354
  }
236
355
 
237
356
  maskInstance = IMask(inputRef.value, maskConfig as any)
@@ -241,12 +360,24 @@ const createMask = () => {
241
360
  maskInstance.value = internalValue.value
242
361
  }
243
362
 
244
- // Update internalValue when mask value changes
245
363
  maskInstance.on('accept', () => {
246
364
  if (maskInstance) {
247
- internalValue.value = maskInstance.value
365
+ internalValue.value = getMaskEmittedValue(maskInstance)
366
+ nextTick(syncMaskDisplay)
248
367
  }
249
368
  })
369
+
370
+ maskInstance.on('complete', () => {
371
+ nextTick(syncMaskDisplay)
372
+ })
373
+
374
+ nextTick(() => {
375
+ syncMaskDisplay()
376
+ nextTick(syncMaskDisplay)
377
+ })
378
+ } else {
379
+ maskDisplayValue.value = ''
380
+ maskPlaceholderChar.value = ''
250
381
  }
251
382
  }
252
383
 
@@ -279,14 +410,15 @@ const ariaDescribedBy = computed(() => {
279
410
  return props.meta ? metaId.value : undefined
280
411
  })
281
412
 
282
- // Watch for mask prop changes and recreate mask
413
+ // Watch for mask-related prop changes and recreate mask
283
414
  watch(
284
- () => props.mask,
415
+ () => [props.mask, props.displayChar, props.maskOptions] as const,
285
416
  () => {
286
417
  nextTick(() => {
287
418
  createMask()
288
419
  })
289
420
  },
421
+ { deep: true },
290
422
  )
291
423
 
292
424
  // Watch for external value changes and update mask
@@ -294,10 +426,11 @@ watch(
294
426
  watch(
295
427
  () => props.value,
296
428
  (newValue) => {
297
- if (maskInstance && newValue !== undefined && newValue !== maskInstance.value) {
429
+ if (maskInstance && newValue !== undefined && newValue !== getMaskEmittedValue(maskInstance)) {
298
430
  // Only update if input doesn't have focus (user is not editing)
299
431
  if (document.activeElement !== inputRef.value) {
300
432
  maskInstance.value = newValue
433
+ nextTick(syncMaskDisplay)
301
434
  }
302
435
  }
303
436
  },
@@ -306,10 +439,11 @@ watch(
306
439
  watch(
307
440
  () => modelValue.value,
308
441
  (newValue) => {
309
- if (maskInstance && newValue !== undefined && newValue !== maskInstance.value) {
442
+ if (maskInstance && newValue !== undefined && newValue !== getMaskEmittedValue(maskInstance)) {
310
443
  // Only update if input doesn't have focus (user is not editing)
311
444
  if (document.activeElement !== inputRef.value) {
312
445
  maskInstance.value = newValue
446
+ nextTick(syncMaskDisplay)
313
447
  }
314
448
  }
315
449
  },
@@ -335,12 +469,11 @@ watch(
335
469
  </FdsMeta>
336
470
  </div>
337
471
  <div :class="{ 'flex-1': props.labelLeft }">
338
- <div class="relative">
472
+ <div class="relative" :class="maskFieldWrapperClasses">
339
473
  <input
340
474
  ref="inputRef"
341
475
  :type="inputType === 'password' ? (showPassword ? 'text' : 'password') : inputType"
342
476
  :required="props.required"
343
- :value="internalValue"
344
477
  :disabled="props.disabled"
345
478
  :tabindex="props.disabled ? -1 : undefined"
346
479
  :aria-invalid="props.valid === false ? 'true' : undefined"
@@ -353,7 +486,7 @@ watch(
353
486
  :placeholder="props.placeholder"
354
487
  :pattern="props.pattern"
355
488
  :searchIcon="props.searchIcon"
356
- v-bind="inputAttrs"
489
+ v-bind="inputBindings"
357
490
  @input="handleInputChange"
358
491
  @change="handleInputChange"
359
492
  @blur="$emit('blur', $event)"
@@ -364,6 +497,13 @@ watch(
364
497
  }
365
498
  "
366
499
  />
500
+ <div v-if="useMaskPlaceholderStyle" :class="maskOverlayClasses" aria-hidden="true">
501
+ <span class="whitespace-pre text-base leading-6 tabular-nums">
502
+ <span v-for="(item, index) in maskDisplayChars" :key="index" :style="maskCharStyle(item.isPlaceholder)">
503
+ {{ item.char }}
504
+ </span>
505
+ </span>
506
+ </div>
367
507
  <div :class="rightIconsContainerClasses">
368
508
  <FdsButtonIcon
369
509
  v-if="showDateIcon"
@@ -9,6 +9,8 @@ export interface FdsInputProps {
9
9
  labelLeft?: boolean
10
10
  clearButton?: boolean
11
11
  mask?: string | RegExp | Array<string | RegExp>
12
+ /** Character shown for unfilled mask positions while typing (maps to IMask placeholderChar). */
13
+ displayChar?: string
12
14
  modelValue?: string
13
15
  value?: string
14
16
  type?: string
@@ -139,7 +139,7 @@ function onNoCountryResults(value: boolean) {
139
139
  <div v-if="meta" class="font-thin mb-1">
140
140
  {{ meta }}
141
141
  </div>
142
- <div class="flex flex-wrap items-start gap-x-1">
142
+ <div class="flex flex-wrap items-start gap-x-2">
143
143
  <FdsPhonenumberCountryPicker
144
144
  v-model="country"
145
145
  :items="countryItems"
@@ -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
  >