@fy-/fws-vue 2.1.44 → 2.1.46

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.
@@ -3,11 +3,10 @@ import type { ErrorObject } from '@vuelidate/core'
3
3
  import { computed, ref, toRef } from 'vue'
4
4
  import { useTranslation } from '../../composables/translations'
5
5
  import DefaultTagInput from './DefaultTagInput.vue'
6
- // import VueTailwindDatepicker from "vue-tailwind-datepicker";
7
6
 
8
7
  type modelValueType = string | number | string[] | number[] | undefined
9
-
10
8
  type checkboxValueType = any[] | Set<any> | undefined | boolean
9
+
11
10
  const props = withDefaults(
12
11
  defineProps<{
13
12
  id: string
@@ -52,29 +51,19 @@ const props = withDefaults(
52
51
  dpOptions: () => ({}),
53
52
  },
54
53
  )
55
- /* function disableDatesAdult(date: Date) {
56
- if (!props.disableDatesUnder18) return false
57
- const today = new Date()
58
- const date18YearsAgo = new Date(
59
- today.getFullYear() - 18,
60
- today.getMonth(),
61
- today.getDate(),
62
- )
63
-
64
- return date >= date18YearsAgo
65
- } */
66
54
 
67
55
  const translate = useTranslation()
68
56
  const inputRef = ref<HTMLInputElement>()
57
+
69
58
  const errorProps = toRef(props, 'error')
70
59
  const errorVuelidateProps = toRef(props, 'errorVuelidate')
60
+
71
61
  const checkErrors = computed(() => {
72
62
  if (errorProps.value) return errorProps.value
73
63
  if (errorVuelidateProps.value && errorVuelidateProps.value.length > 0) {
74
- const err = `vuelidate_validator_${errorVuelidateProps.value[0].$validator.toString()}`
75
- return translate(err)
64
+ const errKey = `vuelidate_validator_${errorVuelidateProps.value[0].$validator.toString()}`
65
+ return translate(errKey)
76
66
  }
77
-
78
67
  return null
79
68
  })
80
69
 
@@ -85,39 +74,43 @@ function blur() {
85
74
  if (inputRef.value) inputRef.value.blur()
86
75
  }
87
76
  function getInputRef() {
88
- if (inputRef.value) return inputRef.value
77
+ return inputRef.value
89
78
  }
79
+
90
80
  const emit = defineEmits([
91
81
  'update:modelValue',
92
82
  'update:checkboxValue',
93
83
  'focus',
94
84
  'blur',
95
85
  ])
86
+
96
87
  function handleFocus() {
97
88
  emit('focus', props.id)
98
89
  }
99
-
100
90
  function handleBlur() {
101
91
  emit('blur', props.id)
102
92
  }
103
93
 
104
- const model = computed({
94
+ const model = computed<modelValueType>({
105
95
  get: () => props.modelValue,
106
- set: (items) => {
107
- emit('update:modelValue', items)
96
+ set: (value) => {
97
+ emit('update:modelValue', value)
108
98
  },
109
99
  })
110
- const modelCheckbox = computed({
100
+
101
+ const modelCheckbox = computed<checkboxValueType>({
111
102
  get: () => props.checkboxValue,
112
- set: (items) => {
113
- emit('update:checkboxValue', items)
103
+ set: (value) => {
104
+ emit('update:checkboxValue', value)
114
105
  },
115
106
  })
107
+
116
108
  defineExpose({ focus, blur, getInputRef })
117
109
  </script>
118
110
 
119
111
  <template>
120
112
  <div>
113
+ <!-- TEXT, PASSWORD, EMAIL, SEARCH, DATE, DATETIME, URL, TEXTAREA, SELECT, PHONE, TEL, RANGE, CHIPS, TAGS, MASK, DATEPICKER -->
121
114
  <template
122
115
  v-if="
123
116
  [
@@ -142,6 +135,7 @@ defineExpose({ focus, blur, getInputRef })
142
135
  "
143
136
  >
144
137
  <div class="flex flex-col gap-2">
138
+ <!-- Basic input types -->
145
139
  <div
146
140
  v-if="
147
141
  [
@@ -162,12 +156,16 @@ defineExpose({ focus, blur, getInputRef })
162
156
  class="relative"
163
157
  >
164
158
  <label
165
- v-if="label"
159
+ v-if="showLabel && label"
166
160
  :for="id"
167
161
  class="block mb-2 text-sm font-medium text-fv-neutral-900 dark:text-white"
168
- >{{ label }}
169
- <template v-if="type === 'range'"> ({{ model }}) </template>
162
+ >
163
+ {{ label }}
164
+ <template v-if="type === 'range'">
165
+ ({{ model }})
166
+ </template>
170
167
  </label>
168
+
171
169
  <input
172
170
  :id="id"
173
171
  ref="inputRef"
@@ -176,29 +174,33 @@ defineExpose({ focus, blur, getInputRef })
176
174
  :name="id"
177
175
  :class="{
178
176
  'error': checkErrors,
179
- 'bg-fv-neutral-50 border border-fv-neutral-300 text-fv-neutral-900 text-sm rounded-lg focus:ring-fv-primary-500 focus:border-fv-primary-500 block w-full p-2.5 dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-400 dark:text-white dark:focus:ring-fv-primary-500 dark:focus:border-fv-primary-500':
180
- type !== 'range',
181
- 'w-full h-2 bg-fv-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-fv-neutral-700':
182
- type === 'range',
177
+ 'bg-fv-neutral-50 border border-fv-neutral-300 text-fv-neutral-900 text-sm rounded-lg focus:ring-fv-primary-500 focus:border-fv-primary-500 block w-full p-2.5 dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-400 dark:text-white dark:focus:ring-fv-primary-500 dark:focus:border-fv-primary-500': type !== 'range',
178
+ 'w-full h-2 bg-fv-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-fv-neutral-700': type === 'range',
183
179
  }"
184
180
  :autocomplete="autocomplete"
185
181
  :min="type === 'range' ? minRange : undefined"
186
182
  :max="type === 'range' ? maxRange : undefined"
187
183
  :placeholder="placeholder"
188
184
  :disabled="disabled"
189
- :aria-describedby="help ? `${id}-help` : id"
185
+ :aria-describedby="help ? `${id}-help` : undefined"
190
186
  :required="req"
187
+ :aria-invalid="checkErrors ? 'true' : 'false'"
191
188
  @focus="handleFocus"
192
189
  @blur="handleBlur"
193
190
  >
191
+
192
+ <!-- Range Input Extra Labels -->
194
193
  <template v-if="type === 'range'">
195
194
  <span
196
195
  class="text-sm text-gray-500 dark:text-gray-400 absolute start-0 -bottom-6"
197
- >Min ({{ minRange }})
196
+ >
197
+ Min ({{ minRange }})
198
198
  </span>
199
199
  <span
200
200
  class="text-sm text-gray-500 dark:text-gray-400 absolute start-1/3 -translate-x-1/2 rtl:translate-x-1/2 -bottom-6"
201
- >{{ ((maxRange - minRange) / 3 + minRange).toFixed(0) }}</span>
201
+ >
202
+ {{ ((maxRange - minRange) / 3 + minRange).toFixed(0) }}
203
+ </span>
202
204
  <span
203
205
  class="text-sm text-gray-500 dark:text-gray-400 absolute start-2/3 -translate-x-1/2 rtl:translate-x-1/2 -bottom-6"
204
206
  >
@@ -206,38 +208,20 @@ defineExpose({ focus, blur, getInputRef })
206
208
  </span>
207
209
  <span
208
210
  class="text-sm text-gray-500 dark:text-gray-400 absolute end-0 -bottom-6"
209
- >Max ({{ maxRange }})</span>
211
+ >
212
+ Max ({{ maxRange }})
213
+ </span>
210
214
  </template>
211
215
  </div>
212
- <!--
213
- <div v-if="type == 'datepicker'">
214
- <label
215
- :for="id"
216
- v-if="label || placeholder"
217
- class="block mb-2 text-sm font-medium text-fv-neutral-900 dark:text-white"
218
- >{{ label ? label : placeholder }}
219
- </label>
220
- <div class="relative">
221
- <VueTailwindDatepicker
222
- v-model="model"
223
- :disable-date="disableDatesAdult"
224
- :formatter="{
225
- date: 'YYYY-MM-DD',
226
- month: 'MMM',
227
- }"
228
- :placeholder="placeholder"
229
- as-single
230
- ></VueTailwindDatepicker>
231
- </div>
232
- </div>
233
- -->
234
216
 
217
+ <!-- CHIPS / TAGS -->
235
218
  <div v-if="type === 'chips' || type === 'tags'">
236
219
  <label
237
- v-if="label || placeholder"
220
+ v-if="showLabel && (label || placeholder)"
238
221
  :for="id"
239
222
  class="block mb-2 text-sm font-medium text-fv-neutral-900 dark:text-white"
240
- >{{ label ? label : placeholder }}
223
+ >
224
+ {{ label ? label : placeholder }}
241
225
  </label>
242
226
  <!-- @vue-skip -->
243
227
  <DefaultTagInput
@@ -251,12 +235,16 @@ defineExpose({ focus, blur, getInputRef })
251
235
  :max-lenght-per-tag="maxLengthPerTag"
252
236
  />
253
237
  </div>
238
+
239
+ <!-- TEXTAREA (AUTO-GROW) -->
254
240
  <div v-else-if="type === 'textarea-grow'" class="group relative">
255
241
  <label
256
- v-if="label"
242
+ v-if="showLabel && label"
257
243
  :for="id"
258
244
  class="block mb-2 text-sm font-medium text-fv-neutral-900 dark:text-white"
259
- >{{ label }}</label>
245
+ >
246
+ {{ label }}
247
+ </label>
260
248
  <div class="grow-wrap">
261
249
  <!-- @vue-skip -->
262
250
  <textarea
@@ -269,9 +257,13 @@ defineExpose({ focus, blur, getInputRef })
269
257
  }"
270
258
  :placeholder="placeholder"
271
259
  :disabled="disabled"
272
- :aria-describedby="help ? `${id}-help` : id"
260
+ :aria-describedby="help ? `${id}-help` : undefined"
273
261
  :required="req"
274
- class="block p-2.5 w-full text-sm text-fv-neutral-900 bg-fv-neutral-50 rounded-lg border border-fv-neutral-300 focus:ring-fv-primary-500 focus:border-fv-primary-500 dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-400 dark:text-white dark:focus:ring-fv-primary-500 dark:focus:border-fv-primary-500"
262
+ :aria-invalid="checkErrors ? 'true' : 'false'"
263
+ class="block p-2.5 w-full text-sm text-fv-neutral-900 bg-fv-neutral-50 rounded-lg
264
+ border border-fv-neutral-300 focus:ring-fv-primary-500 focus:border-fv-primary-500
265
+ dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-400
266
+ dark:text-white dark:focus:ring-fv-primary-500 dark:focus:border-fv-primary-500"
275
267
  @focus="handleFocus"
276
268
  @blur="handleBlur"
277
269
  />
@@ -284,16 +276,19 @@ defineExpose({ focus, blur, getInputRef })
284
276
  model?.toString().length > dpOptions.counterMax,
285
277
  }"
286
278
  >
287
- {{ model?.toString().length }} /
288
- {{ dpOptions.counterMax }} characters
279
+ {{ model?.toString().length }} / {{ dpOptions.counterMax }} characters
289
280
  </div>
290
281
  </div>
282
+
283
+ <!-- TEXTAREA (REGULAR) -->
291
284
  <div v-else-if="type === 'textarea'" class="group relative">
292
285
  <label
293
- v-if="label"
286
+ v-if="showLabel && label"
294
287
  :for="id"
295
288
  class="block mb-2 text-sm font-medium text-fv-neutral-900 dark:text-white"
296
- >{{ label }}</label>
289
+ >
290
+ {{ label }}
291
+ </label>
297
292
  <!-- @vue-skip -->
298
293
  <textarea
299
294
  :id="id"
@@ -305,9 +300,14 @@ defineExpose({ focus, blur, getInputRef })
305
300
  }"
306
301
  :placeholder="placeholder"
307
302
  :disabled="disabled"
308
- :aria-describedby="help ? `${id}-help` : id"
303
+ :aria-describedby="help ? `${id}-help` : undefined"
309
304
  :required="req"
310
- class="block p-2.5 w-full text-sm text-fv-neutral-900 bg-fv-neutral-50 rounded-lg border border-fv-neutral-300 focus:ring-fv-primary-500 focus:border-fv-primary-500 dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-400 dark:text-white dark:focus:ring-fv-primary-500 dark:focus:border-fv-primary-500"
305
+ :aria-invalid="checkErrors ? 'true' : 'false'"
306
+ class="block p-2.5 w-full text-sm text-fv-neutral-900 bg-fv-neutral-50
307
+ rounded-lg border border-fv-neutral-300 focus:ring-fv-primary-500
308
+ focus:border-fv-primary-500 dark:bg-fv-neutral-700 dark:border-fv-neutral-600
309
+ dark:placeholder-fv-neutral-400 dark:text-white dark:focus:ring-fv-primary-500
310
+ dark:focus:border-fv-primary-500"
311
311
  @focus="handleFocus"
312
312
  @blur="handleBlur"
313
313
  />
@@ -319,34 +319,42 @@ defineExpose({ focus, blur, getInputRef })
319
319
  model?.toString().length > dpOptions.counterMax,
320
320
  }"
321
321
  >
322
- {{ model?.toString().length }} /
323
- {{ dpOptions.counterMax }} characters
322
+ {{ model?.toString().length }} / {{ dpOptions.counterMax }} characters
324
323
  </div>
325
324
  </div>
325
+
326
+ <!-- SELECT -->
326
327
  <div v-else-if="type === 'select'" class="relative">
327
328
  <label
328
- v-if="label"
329
+ v-if="showLabel && label"
329
330
  :for="id"
330
331
  class="block mb-2 text-sm font-medium text-fv-neutral-900 dark:text-white"
331
- >{{ label }}</label>
332
+ >
333
+ {{ label }}
334
+ </label>
332
335
  <select
333
336
  :id="id"
334
337
  ref="inputRef"
335
338
  v-model="model"
336
339
  :name="id"
337
340
  :disabled="disabled"
338
- :aria-describedby="help ? `${id}-help` : id"
341
+ :aria-describedby="help ? `${id}-help` : undefined"
339
342
  :required="req"
340
343
  :class="{
341
344
  error: checkErrors,
342
345
  }"
343
- class="bg-fv-neutral-50 border border-fv-neutral-300 text-fv-neutral-900 text-sm rounded-lg focus:ring-fv-primary-500 focus:border-fv-primary-500 block w-full p-2.5 dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-400 dark:text-white dark:focus:ring-fv-primary-500 dark:focus:border-fv-primary-500"
346
+ :aria-invalid="checkErrors ? 'true' : 'false'"
347
+ class="bg-fv-neutral-50 border border-fv-neutral-300 text-fv-neutral-900 text-sm
348
+ rounded-lg focus:ring-fv-primary-500 focus:border-fv-primary-500
349
+ block w-full p-2.5 dark:bg-fv-neutral-700 dark:border-fv-neutral-600
350
+ dark:placeholder-fv-neutral-400 dark:text-white dark:focus:ring-fv-primary-500
351
+ dark:focus:border-fv-primary-500"
344
352
  @focus="handleFocus"
345
353
  @blur="handleBlur"
346
354
  >
347
355
  <option
348
356
  v-for="opt in options"
349
- :key="opt[0].toString()"
357
+ :key="opt[0]?.toString()"
350
358
  :value="opt[0]"
351
359
  >
352
360
  {{ opt[1] }}
@@ -355,6 +363,8 @@ defineExpose({ focus, blur, getInputRef })
355
363
  </div>
356
364
  </div>
357
365
  </template>
366
+
367
+ <!-- TOGGLE (switch) -->
358
368
  <template v-else-if="type === 'toggle'">
359
369
  <label
360
370
  class="inline-flex items-center mb-5 cursor-pointer"
@@ -369,39 +379,60 @@ defineExpose({ focus, blur, getInputRef })
369
379
  :false-value="checkboxFalseValue"
370
380
  :disabled="disabled"
371
381
  class="sr-only peer"
382
+ :aria-invalid="checkErrors ? 'true' : 'false'"
383
+ :aria-describedby="help ? `${id}-help` : undefined"
384
+ :aria-checked="modelCheckbox ? 'true' : 'false'"
385
+ role="switch"
372
386
  @focus="handleFocus"
373
387
  @blur="handleBlur"
374
388
  >
375
389
  <div
376
- class="relative flex-0 flex-shrink-0 w-11 h-6 bg-fv-neutral-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-fv-primary-300 dark:peer-focus:ring-fv-primary-800 rounded-full peer dark:bg-fv-neutral-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-fv-neutral-300 after:border after:rounded-full after:w-5 after:h-5 after:transition-all dark:border-fv-neutral-600 peer-checked:bg-fv-primary-600"
390
+ class="relative flex-0 flex-shrink-0 w-11 h-6 bg-fv-neutral-200 peer-focus:outline-none
391
+ peer-focus:ring-4 peer-focus:ring-fv-primary-300 dark:peer-focus:ring-fv-primary-800
392
+ rounded-full peer dark:bg-fv-neutral-700 peer-checked:after:translate-x-full
393
+ rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white
394
+ after:content-[''] after:absolute after:top-[2px] after:start-[2px]
395
+ after:bg-white after:border-fv-neutral-300 after:border after:rounded-full
396
+ after:w-5 after:h-5 after:transition-all dark:border-fv-neutral-600
397
+ peer-checked:bg-fv-primary-600"
377
398
  />
378
399
  <span
379
400
  class="ms-3 text-sm font-medium text-fv-neutral-900 dark:text-fv-neutral-300"
380
401
  >
381
402
  {{ label }}
382
403
  <template v-if="help">
383
- <p class="text-fv-neutral-600 dark:text-fv-neutral-400 !text-sm">
404
+ <p
405
+ :id="help ? `${id}-help` : undefined"
406
+ class="text-fv-neutral-600 dark:text-fv-neutral-400 !text-sm"
407
+ >
384
408
  {{ help }}
385
409
  </p>
386
410
  </template>
387
411
  </span>
388
412
  </label>
389
413
  </template>
414
+
415
+ <!-- CHECKBOX / RADIO -->
390
416
  <template v-else-if="type === 'checkbox' || type === 'radio'">
391
417
  <div class="flex mb-4">
392
418
  <div class="flex items-center h-5">
393
419
  <input
394
420
  :id="id"
421
+ ref="inputRef"
395
422
  v-model="modelCheckbox"
396
423
  :class="{
397
424
  error: checkErrors,
398
425
  }"
399
- :aria-describedby="help ? `${id}-help` : id"
426
+ :aria-describedby="help ? `${id}-help` : undefined"
400
427
  :type="type"
401
428
  :true-value="checkboxTrueValue"
402
429
  :false-value="checkboxFalseValue"
403
430
  :disabled="disabled"
404
- class="w-4 h-4 text-fv-primary-600 bg-fv-neutral-100 border-fv-neutral-300 rounded focus:ring-fv-primary-500 dark:focus:ring-fv-primary-600 dark:ring-offset-fv-neutral-800 dark:focus:ring-offset-fv-neutral-800 focus:ring-2 dark:bg-fv-neutral-700 dark:border-fv-neutral-600"
431
+ :aria-invalid="checkErrors ? 'true' : 'false'"
432
+ class="w-4 h-4 text-fv-primary-600 bg-fv-neutral-100 border-fv-neutral-300
433
+ rounded focus:ring-fv-primary-500 dark:focus:ring-fv-primary-600
434
+ dark:ring-offset-fv-neutral-800 dark:focus:ring-offset-fv-neutral-800
435
+ focus:ring-2 dark:bg-fv-neutral-700 dark:border-fv-neutral-600"
405
436
  @focus="handleFocus"
406
437
  @blur="handleBlur"
407
438
  >
@@ -410,7 +441,9 @@ defineExpose({ focus, blur, getInputRef })
410
441
  <label
411
442
  :for="id"
412
443
  class="font-medium text-fv-neutral-900 dark:text-fv-neutral-300"
413
- >{{ label }}</label>
444
+ >
445
+ {{ label }}
446
+ </label>
414
447
  <p
415
448
  v-if="help"
416
449
  :id="`${id}-help`"
@@ -421,10 +454,18 @@ defineExpose({ focus, blur, getInputRef })
421
454
  </div>
422
455
  </div>
423
456
  </template>
424
- <p v-if="checkErrors" class="mt-0.5 text-sm text-red-600 dark:text-red-300">
457
+
458
+ <!-- Error message -->
459
+ <p
460
+ v-if="checkErrors"
461
+ class="mt-0.5 text-sm text-red-600 dark:text-red-300"
462
+ role="alert"
463
+ aria-live="assertive"
464
+ >
425
465
  {{ checkErrors }}
426
466
  </p>
427
467
 
468
+ <!-- Help text (for other input types) -->
428
469
  <p
429
470
  v-if="help && !['checkbox', 'radio', 'toggle'].includes(type)"
430
471
  :id="`${id}-help`"
@@ -440,7 +481,7 @@ input,
440
481
  textarea,
441
482
  select {
442
483
  &.error {
443
- @apply border-red-500 dark:border-red-400;
484
+ @apply border-red-500 dark:border-red-400;
444
485
  }
445
486
  }
446
487
  </style>
@@ -1,9 +1,15 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, ref } from 'vue'
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
3
  import { useEventBus } from '../../composables/event-bus'
4
4
 
5
+ /**
6
+ * Tag color variants
7
+ */
5
8
  type colorType = 'blue' | 'red' | 'green' | 'purple' | 'orange' | 'neutral'
6
9
 
10
+ /**
11
+ * Define component properties
12
+ */
7
13
  const props = withDefaults(
8
14
  defineProps<{
9
15
  modelValue: string[]
@@ -14,8 +20,13 @@ const props = withDefaults(
14
20
  autofocus?: boolean
15
21
  help?: string
16
22
  maxLenghtPerTag?: number
23
+ /** Error string; if present, the border turns red and you can display a helper message */
17
24
  error?: string
18
25
  copyButton?: boolean
26
+ /** If true, prevents the user from adding duplicate tags */
27
+ noDuplicates?: boolean
28
+ /** If > 0, sets the maximum number of tags allowed */
29
+ maxTags?: number
19
30
  }>(),
20
31
  {
21
32
  copyButton: false,
@@ -24,12 +35,22 @@ const props = withDefaults(
24
35
  label: 'Tags',
25
36
  separators: () => [','],
26
37
  autofocus: false,
38
+ noDuplicates: false,
39
+ maxTags: 0,
27
40
  },
28
41
  )
29
42
 
43
+ /**
44
+ * Refs & Data
45
+ */
30
46
  const textInput = ref<HTMLElement>()
47
+ const isMaxReached = ref(false)
31
48
 
32
49
  const emit = defineEmits(['update:modelValue'])
50
+
51
+ /**
52
+ * Create a two-way computed property for modelValue
53
+ */
33
54
  const model = computed({
34
55
  get: () => props.modelValue,
35
56
  set: (items) => {
@@ -37,75 +58,151 @@ const model = computed({
37
58
  },
38
59
  })
39
60
 
61
+ /**
62
+ * Watch the model to see if maxTags is reached
63
+ */
64
+ watch(
65
+ () => model.value.length,
66
+ (newLength) => {
67
+ if (props.maxTags && props.maxTags > 0) {
68
+ isMaxReached.value = newLength >= props.maxTags
69
+ }
70
+ },
71
+ { immediate: true },
72
+ )
73
+
74
+ /**
75
+ * Focus on the input if autofocus is enabled
76
+ */
40
77
  onMounted(() => {
41
78
  if (props.autofocus) {
42
79
  focusInput()
43
80
  }
44
81
  })
82
+
83
+ /**
84
+ * Event Bus example (if you'd like notifications)
85
+ */
45
86
  const eventBus = useEventBus()
87
+
88
+ /**
89
+ * Copy the tags to clipboard
90
+ */
46
91
  async function copyText() {
47
92
  const text = model.value.join(', ')
48
93
  await navigator.clipboard.writeText(text)
94
+
95
+ // Example event bus notification
49
96
  eventBus.emit('SendNotif', {
50
- title: 'Text copied!',
97
+ title: 'Tags copied!',
51
98
  type: 'success',
52
99
  time: 2500,
53
100
  })
54
101
  }
55
102
 
56
- function handleInput(event: any) {
103
+ /**
104
+ * On each character input, check if user typed a separator
105
+ */
106
+ function handleInput(event: Event) {
107
+ const inputEvent = event as InputEvent
108
+ if (!inputEvent.data) return
57
109
  const separatorsRegex = new RegExp(props.separators.join('|'))
58
- if (separatorsRegex.test(event.data)) {
110
+ if (separatorsRegex.test(inputEvent.data)) {
59
111
  addTag()
60
112
  }
61
113
  }
62
114
 
115
+ /**
116
+ * Add a tag by splitting on the separator
117
+ */
63
118
  function addTag() {
64
- if (!textInput.value) return
119
+ if (!textInput.value || isMaxReached.value) return
65
120
 
66
121
  const separatorsRegex = new RegExp(props.separators.join('|'))
67
- if (!textInput.value.textContent) return
68
- const newTags = textInput.value.textContent
122
+ const textContent = textInput.value.textContent?.trim()
123
+
124
+ if (!textContent) return
125
+
126
+ const newTags = textContent
69
127
  .split(separatorsRegex)
70
128
  .map((tag: string) => tag.trim())
71
129
  .filter((tag: string) => tag.length > 0)
72
- model.value.push(...newTags)
130
+
131
+ // Remove duplicates if noDuplicates is enabled
132
+ const filteredTags = props.noDuplicates
133
+ ? newTags.filter(tag => !model.value.includes(tag))
134
+ : newTags
135
+
136
+ // If maxTags is set, ensure adding tags doesn't exceed the limit
137
+ if (props.maxTags && props.maxTags > 0) {
138
+ const slotsAvailable = props.maxTags - model.value.length
139
+ filteredTags.splice(slotsAvailable)
140
+ }
141
+
142
+ model.value.push(...filteredTags)
73
143
  textInput.value.textContent = ''
74
144
  }
75
145
 
146
+ /**
147
+ * Remove a tag by index
148
+ */
76
149
  function removeTag(index: number) {
77
150
  model.value.splice(index, 1)
78
151
  focusInput()
79
152
  }
80
153
 
154
+ /**
155
+ * Handle backspace/delete on an empty input
156
+ */
81
157
  function removeLastTag() {
82
158
  if (!textInput.value) return
83
159
  if (textInput.value.textContent === '') {
160
+ // If input is empty, remove the last tag
84
161
  model.value.pop()
85
162
  }
86
163
  else {
87
- if (!textInput.value.textContent) return
88
- textInput.value.textContent = textInput.value.textContent.slice(0, -1)
89
-
90
- const range = document.createRange()
91
- const sel = window.getSelection()
92
- range.selectNodeContents(textInput.value)
93
- range.collapse(false)
94
- if (!sel) return
95
- sel.removeAllRanges()
96
- sel.addRange(range)
164
+ // Otherwise, remove the last character in the input
165
+ if (textInput.value.textContent) {
166
+ textInput.value.textContent = textInput.value.textContent.slice(0, -1)
167
+ }
168
+ placeCursorToEnd()
97
169
  }
98
170
  }
99
- function focusInput() {
171
+
172
+ /**
173
+ * Place the cursor at the end of the contenteditable text
174
+ */
175
+ function placeCursorToEnd() {
100
176
  if (!textInput.value) return
177
+ const range = document.createRange()
178
+ const sel = window.getSelection()
179
+ range.selectNodeContents(textInput.value)
180
+ range.collapse(false)
181
+ if (!sel) return
182
+ sel.removeAllRanges()
183
+ sel.addRange(range)
184
+ }
101
185
 
102
- textInput.value.focus()
186
+ /**
187
+ * Focus the contenteditable input
188
+ */
189
+ function focusInput() {
190
+ if (textInput.value) {
191
+ textInput.value.focus()
192
+ placeCursorToEnd()
193
+ }
103
194
  }
104
195
 
105
- function handlePaste(e: any) {
106
- if (!textInput.value) return
107
- // @ts-expect-error: Property 'clipboardData' does not exist on type 'ClipboardEvent'.
108
- const text = (e.clipboardData || window.clipboardData).getData('text')
196
+ /**
197
+ * Handle pasting text
198
+ */
199
+ function handlePaste(e: ClipboardEvent) {
200
+ if (!textInput.value || isMaxReached.value) return
201
+
202
+ const clipboardData = e.clipboardData ?? (window as any).clipboardData
203
+ if (!clipboardData) return
204
+
205
+ const text = clipboardData.getData('text')
109
206
  const separatorsRegex = new RegExp(props.separators.join('|'), 'g')
110
207
  const pasteText = text.replace(separatorsRegex, ',')
111
208
  textInput.value.textContent += pasteText
@@ -115,16 +212,34 @@ function handlePaste(e: any) {
115
212
  </script>
116
213
 
117
214
  <template>
118
- <div>
215
+ <div class="space-y-1 w-full">
216
+ <!-- Optional label -->
217
+ <label
218
+ v-if="label"
219
+ :for="`tags_${id}`"
220
+ class="block text-sm font-medium dark:text-white"
221
+ >
222
+ {{ label }}
223
+ <!-- optional help text -->
224
+ <span v-if="help" class="ml-1 text-xs text-fv-neutral-500 dark:text-fv-neutral-300">{{ help }}</span>
225
+ </label>
226
+
119
227
  <div
120
- :class="`tags-input ${$props.error ? 'error' : ''}`"
228
+ class="tags-input" :class="[
229
+ $props.error ? 'error' : '',
230
+ isMaxReached ? 'pointer-events-none opacity-75' : '',
231
+ ]"
232
+ role="textbox"
233
+ :aria-label="label || 'Tags input'"
234
+ :aria-invalid="$props.error ? 'true' : 'false'"
121
235
  @click="focusInput"
122
236
  @keydown.delete.prevent="removeLastTag"
123
237
  @keydown.enter.prevent="addTag"
124
238
  >
239
+ <!-- Render each tag -->
125
240
  <span
126
241
  v-for="(tag, index) in model"
127
- :key="index"
242
+ :key="`${tag}-${index}`"
128
243
  class="tag"
129
244
  :class="{
130
245
  red: maxLenghtPerTag > 0 && tag.length > maxLenghtPerTag,
@@ -132,13 +247,18 @@ function handlePaste(e: any) {
132
247
  }"
133
248
  >
134
249
  {{ tag }}
135
- <button type="button" @click.prevent="removeTag(index)">
250
+ <button
251
+ type="button"
252
+ class="flex items-center"
253
+ aria-label="Remove tag"
254
+ @click.prevent="removeTag(index)"
255
+ >
136
256
  <svg
137
- class="w-4 h-4"
257
+ class="w-3 h-3"
138
258
  xmlns="http://www.w3.org/2000/svg"
139
259
  fill="none"
140
260
  viewBox="0 0 24 24"
141
- stroke-width="1.5"
261
+ stroke-width="2"
142
262
  stroke="currentColor"
143
263
  >
144
264
  <path
@@ -149,18 +269,33 @@ function handlePaste(e: any) {
149
269
  </svg>
150
270
  </button>
151
271
  </span>
272
+
273
+ <!-- Contenteditable input for typing/pasting tags -->
152
274
  <div
153
275
  :id="`tags_${id}`"
154
276
  ref="textInput"
155
- contenteditable
277
+ contenteditable="true"
156
278
  class="input"
157
- placeholder="Add a tag..."
279
+ :placeholder="isMaxReached
280
+ ? 'Max tags reached'
281
+ : 'Type or paste and press Enter...'"
158
282
  @input="handleInput"
159
283
  @paste.prevent="handlePaste"
160
284
  />
161
285
  </div>
286
+
287
+ <!-- Inline error display if needed -->
288
+ <p v-if="$props.error" class="text-xs text-red-500 mt-1">
289
+ {{ $props.error }}
290
+ </p>
291
+
292
+ <!-- Copy button / or any additional actions -->
162
293
  <div v-if="copyButton" class="flex justify-end mt-1">
163
- <button class="btn neutral small" type="button" @click.prevent="copyText">
294
+ <button
295
+ class="btn neutral small"
296
+ type="button"
297
+ @click.prevent="copyText"
298
+ >
164
299
  Copy tags
165
300
  </button>
166
301
  </div>
@@ -168,42 +303,65 @@ function handlePaste(e: any) {
168
303
  </template>
169
304
 
170
305
  <style scoped>
306
+ /* Container for all tags plus input */
171
307
  .tags-input {
308
+ @apply w-full flex flex-wrap gap-2 items-center shadow-sm bg-fv-neutral-50
309
+ border border-fv-neutral-300 text-fv-neutral-900 text-sm rounded-sm
310
+ focus-within:ring-fv-primary-500 focus-within:border-fv-primary-500
311
+ p-2.5 dark:bg-fv-neutral-700 dark:border-fv-neutral-600
312
+ dark:placeholder-fv-neutral-400 dark:text-white
313
+ dark:focus-within:ring-fv-primary-500 dark:focus-within:border-fv-primary-500;
172
314
  cursor: text;
173
- @apply flex flex-wrap gap-2 items-center shadow-sm bg-fv-neutral-50 border border-fv-neutral-300 text-fv-neutral-900 text-sm rounded-sm focus:ring-fv-primary-500 focus:border-fv-primary-500 w-full p-2.5 dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-400 dark:text-white dark:focus:ring-fv-primary-500 dark:focus:border-fv-primary-500;
174
- &.error {
175
- @apply border-red-500 dark:border-red-400 border !important;
176
- }
177
315
  }
178
- .tag-label {
179
- @apply block mb-2 text-sm font-medium text-fv-neutral-900 dark:text-white;
316
+
317
+ /* Error border */
318
+ .tags-input.error {
319
+ @apply border-red-500 dark:border-red-400 border !important;
180
320
  }
321
+
322
+ /* Tag styling */
181
323
  .tag {
182
- @apply inline-flex gap-1 font-medium px-2.5 py-0.5 rounded text-black dark:text-white;
183
- &.blue {
184
- @apply bg-blue-400 dark:bg-blue-900;
185
- }
186
- &.red {
187
- @apply bg-red-400 dark:bg-red-900;
188
- }
189
- &.green {
190
- @apply bg-green-400 dark:bg-green-900;
191
- }
192
- &.purple {
193
- @apply bg-purple-400 dark:bg-purple-900;
194
- }
195
- &.orange {
196
- @apply bg-orange-400 dark:bg-orange-900;
197
- }
198
- &.neutral {
199
- @apply bg-fv-neutral-400 dark:bg-fv-neutral-900;
200
- }
324
+ @apply inline-flex gap-1 items-center
325
+ font-medium px-2.5 py-0.5 rounded text-black
326
+ dark:text-white cursor-default;
327
+ }
328
+
329
+ /* Color variants */
330
+ .tag.blue {
331
+ @apply bg-blue-400 dark:bg-blue-800;
332
+ }
333
+ .tag.red {
334
+ @apply bg-red-400 dark:bg-red-800;
335
+ }
336
+ .tag.green {
337
+ @apply bg-green-400 dark:bg-green-800;
338
+ }
339
+ .tag.purple {
340
+ @apply bg-purple-400 dark:bg-purple-800;
341
+ }
342
+ .tag.orange {
343
+ @apply bg-orange-400 dark:bg-orange-800;
344
+ }
345
+ .tag.neutral {
346
+ @apply bg-fv-neutral-400 dark:bg-fv-neutral-900;
201
347
  }
202
348
 
349
+ /* The editable input area for new tags */
203
350
  .input {
204
- flex-grow: 1;
205
- min-width: 100px;
206
- outline: none;
207
- border: none;
351
+ @apply flex-grow min-w-[100px] outline-none border-none break-words;
352
+ }
353
+
354
+ /* Example button classes */
355
+ .btn {
356
+ @apply inline-flex items-center justify-center rounded text-sm px-3 py-1
357
+ border border-transparent font-medium focus:outline-none
358
+ focus-visible:ring-2 focus-visible:ring-offset-2;
359
+ }
360
+ .btn.small {
361
+ @apply text-xs px-2 py-1;
362
+ }
363
+ .btn.neutral {
364
+ @apply bg-fv-neutral-300 hover:bg-fv-neutral-400 text-black
365
+ dark:bg-fv-neutral-600 dark:hover:bg-fv-neutral-500;
208
366
  }
209
367
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.1.44",
3
+ "version": "2.1.46",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",