@fy-/fws-vue 2.2.57 → 2.2.58

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.
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { ErrorObject } from '@vuelidate/core'
3
- import { computed, ref, toRef } from 'vue'
3
+ import { computed, ref, toRef, watch } from 'vue'
4
4
  import { useTranslation } from '../../composables/translations'
5
5
  import DefaultTagInput from './DefaultTagInput.vue'
6
6
 
@@ -54,6 +54,7 @@ const props = withDefaults(
54
54
 
55
55
  const translate = useTranslation()
56
56
  const inputRef = ref<HTMLInputElement>()
57
+ const floatingActive = ref(false)
57
58
 
58
59
  const errorProps = toRef(props, 'error')
59
60
  const errorVuelidateProps = toRef(props, 'errorVuelidate')
@@ -67,6 +68,11 @@ const checkErrors = computed(() => {
67
68
  return null
68
69
  })
69
70
 
71
+ // Determine if floating label should be active (input has value or is focused)
72
+ watch(() => props.modelValue, (newVal) => {
73
+ if (newVal) floatingActive.value = true
74
+ }, { immediate: true })
75
+
70
76
  function focus() {
71
77
  if (inputRef.value) inputRef.value.focus()
72
78
  }
@@ -85,12 +91,21 @@ const emit = defineEmits([
85
91
  ])
86
92
 
87
93
  function handleFocus() {
94
+ floatingActive.value = true
88
95
  emit('focus', props.id)
89
96
  }
90
97
  function handleBlur() {
98
+ if (!props.modelValue) floatingActive.value = false
91
99
  emit('blur', props.id)
92
100
  }
93
101
 
102
+ // Copy input value to clipboard
103
+ function copyToClipboard() {
104
+ if (props.modelValue) {
105
+ navigator.clipboard.writeText(props.modelValue.toString())
106
+ }
107
+ }
108
+
94
109
  const model = computed<modelValueType>({
95
110
  get: () => props.modelValue,
96
111
  set: (value) => {
@@ -155,62 +170,93 @@ defineExpose({ focus, blur, getInputRef })
155
170
  "
156
171
  class="relative"
157
172
  >
173
+ <!-- Static label -->
158
174
  <label
159
- v-if="showLabel && label"
175
+ v-if="showLabel && label && type === 'range'"
160
176
  :for="id"
161
177
  class="block mb-2 text-sm font-medium text-fv-neutral-900 dark:text-white"
162
178
  >
163
- {{ label }}
164
- <template v-if="type === 'range'">
165
- ({{ model }})
166
- </template>
179
+ {{ label }} ({{ model }})
167
180
  </label>
168
181
 
169
- <input
170
- :id="id"
171
- ref="inputRef"
172
- v-model="model"
173
- :type="type === 'datepicker' ? 'date' : type === 'phone' ? 'tel' : type"
174
- :name="id"
182
+ <!-- Floating label (for all except range) -->
183
+ <label
184
+ v-if="showLabel && label && type !== 'range'"
185
+ :for="id"
186
+ class="absolute text-sm duration-300 transform -translate-y-4 scale-75 top-2 z-10 origin-[0] bg-fv-neutral-50 dark:bg-fv-neutral-700 px-2 peer-focus:px-2 peer-placeholder-shown:scale-100 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:top-1/2 peer-focus:top-2 peer-focus:scale-75 peer-focus:-translate-y-4 rtl:peer-focus:translate-x-1/4 rtl:peer-focus:left-auto start-1 pointer-events-none rounded"
175
187
  :class="{
176
- 'error': checkErrors,
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',
188
+ 'text-fv-neutral-600 dark:text-fv-neutral-400': !floatingActive,
189
+ 'text-fv-primary-600 dark:text-fv-primary-400': floatingActive,
190
+ 'text-red-600 dark:text-red-400': checkErrors,
191
+ '-translate-y-4 scale-75 top-2 bg-fv-neutral-50 dark:bg-fv-neutral-700 px-2': floatingActive,
192
+ '-translate-y-1/2 scale-100 top-1/2 bg-transparent': !floatingActive,
179
193
  }"
180
- :autocomplete="autocomplete"
181
- :min="type === 'range' ? minRange : undefined"
182
- :max="type === 'range' ? maxRange : undefined"
183
- :placeholder="placeholder"
184
- :disabled="disabled"
185
- :aria-describedby="help ? `${id}-help` : undefined"
186
- :required="req"
187
- :aria-invalid="checkErrors ? 'true' : 'false'"
188
- @focus="handleFocus"
189
- @blur="handleBlur"
190
194
  >
195
+ {{ label }}
196
+ </label>
191
197
 
192
- <!-- Range Input Extra Labels -->
193
- <template v-if="type === 'range'">
194
- <span
195
- class="text-sm text-gray-500 dark:text-gray-400 absolute start-0 -bottom-6"
196
- >
197
- Min ({{ minRange }})
198
- </span>
199
- <span
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
- >
202
- {{ ((maxRange - minRange) / 3 + minRange).toFixed(0) }}
203
- </span>
204
- <span
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"
198
+ <!-- Input element -->
199
+ <div class="relative">
200
+ <input
201
+ :id="id"
202
+ ref="inputRef"
203
+ v-model="model"
204
+ :type="type === 'datepicker' ? 'date' : type === 'phone' ? 'tel' : type"
205
+ :name="id"
206
+ :class="{
207
+ 'error': checkErrors,
208
+ 'input-with-floating-label': showLabel && label && type !== 'range',
209
+ 'bg-fv-neutral-50 border border-fv-neutral-300 text-fv-neutral-900 text-sm rounded-lg block w-full dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-400 dark:text-white transition-all duration-200': type !== 'range',
210
+ 'peer h-10 px-4 pt-5 pb-2': showLabel && label && type !== 'range',
211
+ 'p-2.5': !(showLabel && label) || type === 'range',
212
+ 'border-fv-primary-500 dark:border-fv-primary-500 focus:border-fv-primary-600 dark:focus:border-fv-primary-600': floatingActive && !checkErrors,
213
+ 'focus:ring-2 focus:ring-fv-primary-300 dark:focus:ring-fv-primary-800 focus:ring-opacity-50': type !== 'range',
214
+ 'w-full h-3 bg-fv-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-fv-neutral-700': type === 'range',
215
+ 'pr-10': copyButton,
216
+ }"
217
+ :autocomplete="autocomplete"
218
+ :min="type === 'range' ? minRange : undefined"
219
+ :max="type === 'range' ? maxRange : undefined"
220
+ :placeholder="(!showLabel || !label) ? placeholder : ' '"
221
+ :disabled="disabled"
222
+ :aria-describedby="help ? `${id}-help` : undefined"
223
+ :required="req"
224
+ :aria-invalid="checkErrors ? 'true' : 'false'"
225
+ @focus="handleFocus"
226
+ @blur="handleBlur"
206
227
  >
207
- {{ (((maxRange - minRange) / 3) * 2 + minRange).toFixed(0) }}
208
- </span>
209
- <span
210
- class="text-sm text-gray-500 dark:text-gray-400 absolute end-0 -bottom-6"
228
+
229
+ <!-- Copy button -->
230
+ <button
231
+ v-if="copyButton && model"
232
+ type="button"
233
+ aria-label="Copy to clipboard"
234
+ class="absolute inset-y-0 right-0 flex items-center pr-3 text-fv-neutral-500 hover:text-fv-primary-600 dark:text-fv-neutral-400 dark:hover:text-fv-primary-400"
235
+ @click="copyToClipboard"
211
236
  >
212
- Max ({{ maxRange }})
213
- </span>
237
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
238
+ <path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
239
+ <path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
240
+ </svg>
241
+ </button>
242
+ </div>
243
+
244
+ <!-- Range Input Extra Labels -->
245
+ <template v-if="type === 'range'">
246
+ <div class="relative pt-6 mt-2">
247
+ <div class="flex justify-between gap-1 text-xs text-fv-neutral-500 dark:text-fv-neutral-400 px-1">
248
+ <span>{{ minRange }}</span>
249
+ <span>{{ ((maxRange - minRange) / 3 + minRange).toFixed(0) }}</span>
250
+ <span>{{ (((maxRange - minRange) / 3) * 2 + minRange).toFixed(0) }}</span>
251
+ <span>{{ maxRange }}</span>
252
+ </div>
253
+ <div class="w-full h-1 bg-fv-neutral-200 dark:bg-fv-neutral-700 rounded-full mt-1">
254
+ <div
255
+ class="h-full bg-fv-primary-500 dark:bg-fv-primary-600 rounded-full"
256
+ :style="{ width: `${((model as number || minRange) - minRange) / (maxRange - minRange) * 100}%` }"
257
+ />
258
+ </div>
259
+ </div>
214
260
  </template>
215
261
  </div>
216
262
 
@@ -261,16 +307,17 @@ defineExpose({ focus, blur, getInputRef })
261
307
  :required="req"
262
308
  :aria-invalid="checkErrors ? 'true' : 'false'"
263
309
  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
310
+ border border-fv-neutral-300 focus:ring-2 focus:ring-fv-primary-300 focus:border-fv-primary-500
265
311
  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"
312
+ dark:text-white dark:focus:ring-fv-primary-800 dark:focus:border-fv-primary-500
313
+ transition-colors duration-200 shadow-sm"
267
314
  @focus="handleFocus"
268
315
  @blur="handleBlur"
269
316
  />
270
317
  </div>
271
318
  <div
272
319
  v-if="dpOptions.counterMax && model"
273
- class="text-sm text-fv-neutral-500 dark:text-fv-neutral-400"
320
+ class="text-sm text-fv-neutral-500 dark:text-fv-neutral-400 mt-1 text-right"
274
321
  :class="{
275
322
  'text-red-500 dark:text-red-300':
276
323
  model?.toString().length > dpOptions.counterMax,
@@ -304,16 +351,16 @@ defineExpose({ focus, blur, getInputRef })
304
351
  :required="req"
305
352
  :aria-invalid="checkErrors ? 'true' : 'false'"
306
353
  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"
354
+ rounded-lg border border-fv-neutral-300 focus:ring-2 focus:ring-fv-primary-300 focus:border-fv-primary-500
355
+ dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-400
356
+ dark:text-white dark:focus:ring-fv-primary-800 dark:focus:border-fv-primary-500
357
+ transition-colors duration-200 shadow-sm min-h-[100px]"
311
358
  @focus="handleFocus"
312
359
  @blur="handleBlur"
313
360
  />
314
361
  <div
315
362
  v-if="dpOptions.counterMax && model"
316
- class="text-sm text-fv-neutral-500 dark:text-fv-neutral-400"
363
+ class="text-sm text-fv-neutral-500 dark:text-fv-neutral-400 mt-1 text-right"
317
364
  :class="{
318
365
  'text-red-500 dark:text-red-300':
319
366
  model?.toString().length > dpOptions.counterMax,
@@ -332,34 +379,42 @@ defineExpose({ focus, blur, getInputRef })
332
379
  >
333
380
  {{ label }}
334
381
  </label>
335
- <select
336
- :id="id"
337
- ref="inputRef"
338
- v-model="model"
339
- :name="id"
340
- :disabled="disabled"
341
- :aria-describedby="help ? `${id}-help` : undefined"
342
- :required="req"
343
- :class="{
344
- error: checkErrors,
345
- }"
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"
352
- @focus="handleFocus"
353
- @blur="handleBlur"
354
- >
355
- <option
356
- v-for="opt in options"
357
- :key="opt[0]?.toString()"
358
- :value="opt[0]"
382
+ <div class="relative">
383
+ <select
384
+ :id="id"
385
+ ref="inputRef"
386
+ v-model="model"
387
+ :name="id"
388
+ :disabled="disabled"
389
+ :aria-describedby="help ? `${id}-help` : undefined"
390
+ :required="req"
391
+ :class="{
392
+ error: checkErrors,
393
+ }"
394
+ :aria-invalid="checkErrors ? 'true' : 'false'"
395
+ class="appearance-none bg-fv-neutral-50 border border-fv-neutral-300 text-fv-neutral-900 text-sm
396
+ rounded-lg focus:ring-2 focus:ring-fv-primary-300 focus:border-fv-primary-500
397
+ block w-full p-2.5 dark:bg-fv-neutral-700 dark:border-fv-neutral-600
398
+ dark:placeholder-fv-neutral-400 dark:text-white dark:focus:ring-fv-primary-800
399
+ dark:focus:border-fv-primary-500 shadow-sm transition-colors duration-200 pr-10"
400
+ @focus="handleFocus"
401
+ @blur="handleBlur"
359
402
  >
360
- {{ opt[1] }}
361
- </option>
362
- </select>
403
+ <option
404
+ v-for="opt in options"
405
+ :key="opt[0]?.toString()"
406
+ :value="opt[0]"
407
+ >
408
+ {{ opt[1] }}
409
+ </option>
410
+ </select>
411
+ <!-- Dropdown arrow icon -->
412
+ <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-fv-neutral-700 dark:text-fv-neutral-300">
413
+ <svg class="h-4 w-4 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
414
+ <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
415
+ </svg>
416
+ </div>
417
+ </div>
363
418
  </div>
364
419
  </div>
365
420
  </template>
@@ -367,9 +422,10 @@ defineExpose({ focus, blur, getInputRef })
367
422
  <!-- TOGGLE (switch) -->
368
423
  <template v-else-if="type === 'toggle'">
369
424
  <label
370
- class="inline-flex items-center mb-5 cursor-pointer"
425
+ class="inline-flex items-center mb-5 cursor-pointer group"
371
426
  :class="{
372
- error: checkErrors,
427
+ 'error': checkErrors,
428
+ 'opacity-70 cursor-not-allowed': disabled,
373
429
  }"
374
430
  >
375
431
  <input
@@ -394,7 +450,7 @@ defineExpose({ focus, blur, getInputRef })
394
450
  after:content-[''] after:absolute after:top-[2px] after:start-[2px]
395
451
  after:bg-white after:border-fv-neutral-300 after:border after:rounded-full
396
452
  after:w-5 after:h-5 after:transition-all dark:border-fv-neutral-600
397
- peer-checked:bg-fv-primary-600"
453
+ peer-checked:bg-fv-primary-600 shadow-sm group-hover:shadow-md transition-all duration-200"
398
454
  />
399
455
  <span
400
456
  class="ms-3 text-sm font-medium text-fv-neutral-900 dark:text-fv-neutral-300"
@@ -414,14 +470,16 @@ defineExpose({ focus, blur, getInputRef })
414
470
 
415
471
  <!-- CHECKBOX / RADIO -->
416
472
  <template v-else-if="type === 'checkbox' || type === 'radio'">
417
- <div class="flex mb-4">
473
+ <div class="flex mb-4" :class="{ 'opacity-70 cursor-not-allowed': disabled }">
418
474
  <div class="flex items-center h-5">
419
475
  <input
420
476
  :id="id"
421
477
  ref="inputRef"
422
478
  v-model="modelCheckbox"
423
479
  :class="{
424
- error: checkErrors,
480
+ 'error': checkErrors,
481
+ 'cursor-not-allowed': disabled,
482
+ 'cursor-pointer': !disabled,
425
483
  }"
426
484
  :aria-describedby="help ? `${id}-help` : undefined"
427
485
  :type="type"
@@ -432,7 +490,8 @@ defineExpose({ focus, blur, getInputRef })
432
490
  class="w-4 h-4 text-fv-primary-600 bg-fv-neutral-100 border-fv-neutral-300
433
491
  rounded focus:ring-fv-primary-500 dark:focus:ring-fv-primary-600
434
492
  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"
493
+ focus:ring-2 dark:bg-fv-neutral-700 dark:border-fv-neutral-600
494
+ transition-colors duration-200 shadow-sm"
436
495
  @focus="handleFocus"
437
496
  @blur="handleBlur"
438
497
  >
@@ -440,7 +499,8 @@ defineExpose({ focus, blur, getInputRef })
440
499
  <div class="ms-2 text-sm">
441
500
  <label
442
501
  :for="id"
443
- class="font-medium text-fv-neutral-900 dark:text-fv-neutral-300"
502
+ class="font-medium text-fv-neutral-900 dark:text-fv-neutral-300 cursor-pointer"
503
+ :class="{ 'cursor-not-allowed': disabled }"
444
504
  >
445
505
  {{ label }}
446
506
  </label>
@@ -458,10 +518,13 @@ defineExpose({ focus, blur, getInputRef })
458
518
  <!-- Error message -->
459
519
  <p
460
520
  v-if="checkErrors"
461
- class="mt-0.5 text-sm text-red-600 dark:text-red-300"
521
+ class="mt-0.5 text-sm text-red-600 dark:text-red-300 flex items-center"
462
522
  role="alert"
463
523
  aria-live="assertive"
464
524
  >
525
+ <svg class="w-4 h-4 mr-1.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
526
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
527
+ </svg>
465
528
  {{ checkErrors }}
466
529
  </p>
467
530
 
@@ -481,7 +544,60 @@ input,
481
544
  textarea,
482
545
  select {
483
546
  &.error {
484
- @apply border-red-500 dark:border-red-400;
547
+ @apply border-red-500 dark:border-red-400 focus:border-red-500 dark:focus:border-red-400 focus:ring-red-200 dark:focus:ring-red-800;
485
548
  }
486
549
  }
550
+
551
+ /* Range input styling */
552
+ input[type="range"] {
553
+ @apply appearance-none bg-transparent;
554
+ }
555
+
556
+ input[type="range"]::-webkit-slider-thumb {
557
+ @apply appearance-none w-4 h-4 rounded-full bg-fv-primary-500 dark:bg-fv-primary-600 cursor-pointer border-0 shadow-md;
558
+ margin-top: -0.5rem;
559
+ }
560
+
561
+ input[type="range"]::-moz-range-thumb {
562
+ @apply w-4 h-4 rounded-full bg-fv-primary-500 dark:bg-fv-primary-600 cursor-pointer border-0 shadow-md;
563
+ }
564
+
565
+ input[type="range"]:focus::-webkit-slider-thumb {
566
+ @apply ring-2 ring-fv-primary-300 dark:ring-fv-primary-800;
567
+ }
568
+
569
+ input[type="range"]:focus::-moz-range-thumb {
570
+ @apply ring-2 ring-fv-primary-300 dark:ring-fv-primary-800;
571
+ }
572
+
573
+ /* Textarea auto-grow */
574
+ .grow-wrap {
575
+ @apply grid;
576
+ }
577
+ .grow-wrap::after {
578
+ content: attr(data-replicated-value) " ";
579
+ white-space: pre-wrap;
580
+ visibility: hidden;
581
+ }
582
+ .grow-wrap > textarea {
583
+ resize: none;
584
+ overflow: hidden;
585
+ }
586
+ .grow-wrap > textarea,
587
+ .grow-wrap::after {
588
+ grid-area: 1 / 1 / 2 / 2;
589
+ }
590
+
591
+ /* Add smooth transitions */
592
+ input, select, textarea, input[type="range"]::-webkit-slider-thumb, input[type="range"]::-moz-range-thumb {
593
+ @apply transition-all duration-200;
594
+ }
595
+
596
+ /* Floating label styling */
597
+ .input-with-floating-label::placeholder {
598
+ @apply opacity-0;
599
+ }
600
+ .input-with-floating-label:focus::placeholder {
601
+ @apply opacity-100 transition-opacity duration-300;
602
+ }
487
603
  </style>
@@ -45,6 +45,7 @@ const props = withDefaults(
45
45
  */
46
46
  const textInput = ref<HTMLElement>()
47
47
  const isMaxReached = ref(false)
48
+ const inputContainer = ref<HTMLElement>()
48
49
 
49
50
  const emit = defineEmits(['update:modelValue'])
50
51
 
@@ -62,7 +63,7 @@ const model = computed({
62
63
  * Compute aria-describedby IDs if help or error exist
63
64
  */
64
65
  const describedByIds = computed(() => {
65
- const ids: any[] = []
66
+ const ids: string[] = []
66
67
  if (props.help) {
67
68
  ids.push(`help_tags_${props.id}`)
68
69
  }
@@ -95,7 +96,7 @@ onMounted(() => {
95
96
  })
96
97
 
97
98
  /**
98
- * Event Bus example (if you'd like notifications)
99
+ * Event Bus for notifications
99
100
  */
100
101
  const eventBus = useEventBus()
101
102
 
@@ -104,14 +105,23 @@ const eventBus = useEventBus()
104
105
  */
105
106
  async function copyText() {
106
107
  const text = model.value.join(', ')
107
- await navigator.clipboard.writeText(text)
108
-
109
- // Example event bus notification
110
- eventBus.emit('SendNotif', {
111
- title: 'Tags copied!',
112
- type: 'success',
113
- time: 2500,
114
- })
108
+
109
+ try {
110
+ await navigator.clipboard.writeText(text)
111
+
112
+ eventBus.emit('SendNotif', {
113
+ title: 'Tags copied!',
114
+ type: 'success',
115
+ time: 2500,
116
+ })
117
+ }
118
+ catch {
119
+ eventBus.emit('SendNotif', {
120
+ title: 'Failed to copy tags',
121
+ type: 'error',
122
+ time: 2500,
123
+ })
124
+ }
115
125
  }
116
126
 
117
127
  /**
@@ -153,7 +163,7 @@ function addTag() {
153
163
  filteredTags.splice(slotsAvailable)
154
164
  }
155
165
 
156
- model.value.push(...filteredTags)
166
+ model.value = [...model.value, ...filteredTags]
157
167
  textInput.value.textContent = ''
158
168
  }
159
169
 
@@ -161,7 +171,9 @@ function addTag() {
161
171
  * Remove a tag by index
162
172
  */
163
173
  function removeTag(index: number) {
164
- model.value.splice(index, 1)
174
+ const newTags = [...model.value]
175
+ newTags.splice(index, 1)
176
+ model.value = newTags
165
177
  focusInput()
166
178
  }
167
179
 
@@ -172,7 +184,11 @@ function removeLastTag() {
172
184
  if (!textInput.value) return
173
185
  if (textInput.value.textContent === '') {
174
186
  // If input is empty, remove the last tag
175
- model.value.pop()
187
+ if (model.value.length > 0) {
188
+ const newTags = [...model.value]
189
+ newTags.pop()
190
+ model.value = newTags
191
+ }
176
192
  }
177
193
  else {
178
194
  // Otherwise, remove the last character in the input
@@ -223,33 +239,49 @@ function handlePaste(e: ClipboardEvent) {
223
239
  e.preventDefault()
224
240
  addTag()
225
241
  }
242
+
243
+ /**
244
+ * Handle keyboard navigation between tags
245
+ */
246
+ function handleKeyNavigation(e: KeyboardEvent, index: number) {
247
+ if (e.key === 'ArrowLeft' && index > 0) {
248
+ const prevTag = inputContainer.value?.querySelector(`[data-index="${index - 1}"] button`) as HTMLElement
249
+ if (prevTag) prevTag.focus()
250
+ }
251
+ else if (e.key === 'ArrowRight' && index < model.value.length - 1) {
252
+ const nextTag = inputContainer.value?.querySelector(`[data-index="${index + 1}"] button`) as HTMLElement
253
+ if (nextTag) nextTag.focus()
254
+ }
255
+ }
226
256
  </script>
227
257
 
228
258
  <template>
229
- <div class="space-y-1 w-full">
230
- <!-- Optional label -->
231
- <label
232
- v-if="label"
233
- :id="`label_tags_${id}`"
234
- :for="`tags_${id}`"
235
- class="block text-sm font-medium dark:text-white"
236
- >
237
- {{ label }}
259
+ <div class="space-y-2 w-full">
260
+ <!-- Optional label with help text -->
261
+ <div v-if="label" class="flex items-center flex-wrap gap-1">
262
+ <label
263
+ :id="`label_tags_${id}`"
264
+ :for="`tags_${id}`"
265
+ class="block text-sm font-medium dark:text-white"
266
+ >
267
+ {{ label }}
268
+ </label>
238
269
  <!-- Optional help text -->
239
270
  <span
240
271
  v-if="help"
241
272
  :id="`help_tags_${id}`"
242
- class="ml-1 text-xs text-fv-neutral-500 dark:text-fv-neutral-300"
273
+ class="text-xs text-fv-neutral-500 dark:text-fv-neutral-300"
243
274
  >
244
275
  {{ help }}
245
276
  </span>
246
- </label>
277
+ </div>
247
278
 
248
279
  <div
280
+ ref="inputContainer"
249
281
  class="tags-input"
250
282
  :class="[
251
283
  $props.error ? 'error' : '',
252
- isMaxReached ? 'pointer-events-none opacity-75' : '',
284
+ isMaxReached ? 'max-reached' : '',
253
285
  ]"
254
286
  role="textbox"
255
287
  :aria-labelledby="`label_tags_${id}`"
@@ -264,31 +296,34 @@ function handlePaste(e: ClipboardEvent) {
264
296
  v-for="(tag, index) in model"
265
297
  :key="`${tag}-${index}`"
266
298
  role="listitem"
299
+ :data-index="index"
267
300
  class="tag"
268
301
  :class="{
269
- red: maxLenghtPerTag > 0 && tag.length > maxLenghtPerTag,
270
- [color]: maxLenghtPerTag === 0 || tag.length <= maxLenghtPerTag,
302
+ 'tag-error': maxLenghtPerTag > 0 && tag.length > maxLenghtPerTag,
303
+ [`tag-${color}`]: maxLenghtPerTag === 0 || tag.length <= maxLenghtPerTag,
271
304
  }"
272
305
  >
273
- {{ tag }}
306
+ <span class="tag-text">{{ tag }}</span>
274
307
  <button
275
308
  type="button"
276
- class="flex items-center"
309
+ class="tag-remove"
277
310
  :aria-label="`Remove tag ${tag}`"
278
311
  @click.prevent="removeTag(index)"
312
+ @keydown="(e) => handleKeyNavigation(e, index)"
279
313
  >
280
314
  <svg
281
- class="w-4 h-4"
315
+ class="w-3.5 h-3.5"
282
316
  xmlns="http://www.w3.org/2000/svg"
283
317
  fill="none"
284
318
  viewBox="0 0 24 24"
285
- stroke-width="2"
319
+ stroke-width="2.5"
286
320
  stroke="currentColor"
321
+ aria-hidden="true"
287
322
  >
288
323
  <path
289
324
  stroke-linecap="round"
290
325
  stroke-linejoin="round"
291
- d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
326
+ d="M6 18L18 6M6 6l12 12"
292
327
  />
293
328
  </svg>
294
329
  </button>
@@ -301,7 +336,7 @@ function handlePaste(e: ClipboardEvent) {
301
336
  contenteditable="true"
302
337
  tabindex="0"
303
338
  class="input"
304
- :placeholder="isMaxReached
339
+ :data-placeholder="isMaxReached
305
340
  ? 'Max tags reached'
306
341
  : 'Type or paste and press Enter...'"
307
342
  :aria-placeholder="isMaxReached
@@ -312,6 +347,11 @@ function handlePaste(e: ClipboardEvent) {
312
347
  />
313
348
  </div>
314
349
 
350
+ <!-- Tag counter when maxTags is set -->
351
+ <div v-if="maxTags > 0" class="tag-counter">
352
+ <span>{{ model.length }}/{{ maxTags }}</span>
353
+ </div>
354
+
315
355
  <!-- Inline error display if needed -->
316
356
  <p
317
357
  v-if="$props.error"
@@ -323,12 +363,28 @@ function handlePaste(e: ClipboardEvent) {
323
363
  </p>
324
364
 
325
365
  <!-- Copy button / or any additional actions -->
326
- <div v-if="copyButton" class="flex justify-end mt-1">
366
+ <div v-if="copyButton" class="copy-button-container">
327
367
  <button
328
- class="btn neutral small"
368
+ class="copy-button"
329
369
  type="button"
370
+ :disabled="model.length === 0"
330
371
  @click.prevent="copyText"
331
372
  >
373
+ <svg
374
+ class="w-4 h-4 mr-1"
375
+ xmlns="http://www.w3.org/2000/svg"
376
+ fill="none"
377
+ viewBox="0 0 24 24"
378
+ stroke-width="2"
379
+ stroke="currentColor"
380
+ aria-hidden="true"
381
+ >
382
+ <path
383
+ stroke-linecap="round"
384
+ stroke-linejoin="round"
385
+ d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75"
386
+ />
387
+ </svg>
332
388
  Copy tags
333
389
  </button>
334
390
  </div>
@@ -338,49 +394,126 @@ function handlePaste(e: ClipboardEvent) {
338
394
  <style scoped>
339
395
  /* Container for all tags plus input */
340
396
  .tags-input {
341
- @apply w-full flex flex-wrap gap-2 items-center shadow-sm bg-fv-neutral-50
342
- border border-fv-neutral-300 text-fv-neutral-900 text-sm rounded-sm
343
- focus-within:ring-fv-primary-500 focus-within:border-fv-primary-500
344
- dark:bg-fv-neutral-700 dark:border-fv-neutral-600
345
- dark:placeholder-fv-neutral-400 dark:text-white p-1.5
346
- dark:focus-within:ring-fv-primary-500 dark:focus-within:border-fv-primary-500;
397
+ @apply w-full flex flex-wrap gap-2 items-center bg-white
398
+ border border-fv-neutral-300 text-fv-neutral-900 text-sm rounded-md
399
+ focus-within:ring-2 focus-within:ring-fv-primary-500 focus-within:border-fv-primary-500
400
+ dark:bg-fv-neutral-800 dark:border-fv-neutral-600
401
+ dark:placeholder-fv-neutral-400 dark:text-white p-2
402
+ dark:focus-within:ring-fv-primary-500 dark:focus-within:border-fv-primary-500
403
+ transition-all duration-200 ease-in-out shadow-sm;
347
404
  cursor: text;
405
+ min-height: 2.5rem;
406
+ }
407
+
408
+ /* Max tags reached state */
409
+ .tags-input.max-reached {
410
+ @apply opacity-80 pointer-events-none bg-fv-neutral-100 dark:bg-fv-neutral-900;
348
411
  }
349
412
 
350
413
  /* Error border */
351
414
  .tags-input.error {
352
- @apply border-red-500 dark:border-red-400 border !important;
415
+ @apply border-red-500 dark:border-red-400 ring-1 ring-red-500 dark:ring-red-400 !important;
353
416
  }
354
417
 
355
- /* Tag styling */
418
+ /* Tag base styling */
356
419
  .tag {
357
- @apply inline-flex gap-1 items-center
358
- font-medium px-2.5 py-1 rounded text-black
359
- dark:text-white cursor-default !important;
420
+ @apply inline-flex items-center justify-between
421
+ text-sm font-medium rounded-full px-3 py-1
422
+ dark:text-white transition-all duration-200 ease-in-out;
360
423
  }
361
424
 
362
- /* Color variants */
363
- .tag.blue {
364
- @apply bg-blue-400 dark:bg-blue-800;
425
+ .tag-text {
426
+ @apply mr-1.5 truncate max-w-[200px];
365
427
  }
366
- .tag.red {
367
- @apply bg-red-400 dark:bg-red-800;
428
+
429
+ /* Tag remove button */
430
+ .tag-remove {
431
+ @apply rounded-full p-0.5 flex items-center justify-center
432
+ hover:bg-black/10 dark:hover:bg-white/20
433
+ focus:outline-none focus:ring-2 focus:ring-offset-1
434
+ transition-colors duration-200;
435
+ height: 18px;
436
+ width: 18px;
368
437
  }
369
- .tag.green {
370
- @apply bg-green-400 dark:bg-green-800;
438
+
439
+ /* Color variants with modern styling */
440
+ .tag-blue {
441
+ @apply bg-blue-100 text-blue-800
442
+ dark:bg-blue-900/70 dark:text-blue-100
443
+ ring-1 ring-blue-400/30 dark:ring-blue-700/30;
371
444
  }
372
- .tag.purple {
373
- @apply bg-purple-400 dark:bg-purple-800;
445
+
446
+ .tag-red, .tag-error {
447
+ @apply bg-red-100 text-red-800
448
+ dark:bg-red-900/70 dark:text-red-100
449
+ ring-1 ring-red-400/30 dark:ring-red-700/30;
450
+ }
451
+
452
+ .tag-green {
453
+ @apply bg-green-100 text-green-800
454
+ dark:bg-green-900/70 dark:text-green-100
455
+ ring-1 ring-green-400/30 dark:ring-green-700/30;
456
+ }
457
+
458
+ .tag-purple {
459
+ @apply bg-purple-100 text-purple-800
460
+ dark:bg-purple-900/70 dark:text-purple-100
461
+ ring-1 ring-purple-400/30 dark:ring-purple-700/30;
374
462
  }
375
- .tag.orange {
376
- @apply bg-orange-400 dark:bg-orange-800;
463
+
464
+ .tag-orange {
465
+ @apply bg-orange-100 text-orange-800
466
+ dark:bg-orange-900/70 dark:text-orange-100
467
+ ring-1 ring-orange-400/30 dark:ring-orange-700/30;
377
468
  }
378
- .tag.neutral {
379
- @apply bg-fv-neutral-400 dark:bg-fv-neutral-900;
469
+
470
+ .tag-neutral {
471
+ @apply bg-fv-neutral-100 text-fv-neutral-800
472
+ dark:bg-fv-neutral-700/70 dark:text-fv-neutral-100
473
+ ring-1 ring-fv-neutral-400/30 dark:ring-fv-neutral-500/30;
380
474
  }
381
475
 
382
476
  /* The editable input area for new tags */
383
477
  .input {
384
- @apply flex-grow min-w-[100px] outline-none border-none break-words;
478
+ @apply flex-grow min-w-[100px] outline-none border-none break-words p-1;
479
+ min-height: 1.5rem;
480
+ }
481
+
482
+ /* Placeholder styling */
483
+ .input:empty:before {
484
+ content: attr(data-placeholder);
485
+ @apply text-fv-neutral-400 dark:text-fv-neutral-500;
486
+ }
487
+
488
+ /* Copy button styling */
489
+ .copy-button-container {
490
+ @apply flex justify-end mt-2;
491
+ }
492
+
493
+ .copy-button {
494
+ @apply inline-flex items-center justify-center
495
+ bg-fv-neutral-100 hover:bg-fv-neutral-200
496
+ text-fv-neutral-700 dark:text-fv-neutral-200
497
+ dark:bg-fv-neutral-700 dark:hover:bg-fv-neutral-600
498
+ px-3 py-1.5 rounded-md text-sm font-medium
499
+ transition-colors duration-200 ease-in-out
500
+ focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-fv-primary-500
501
+ disabled:opacity-50 disabled:cursor-not-allowed;
502
+ }
503
+
504
+ /* Tag counter styling */
505
+ .tag-counter {
506
+ @apply text-xs text-right text-fv-neutral-500 dark:text-fv-neutral-400 mt-1;
507
+ }
508
+
509
+ /* Responsive adjustments */
510
+ @media (max-width: 640px) {
511
+ .tag-text {
512
+ @apply max-w-[120px];
513
+ }
514
+
515
+ .tags-input {
516
+ @apply p-1.5;
517
+ }
385
518
  }
386
519
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fy-/fws-vue",
3
- "version": "2.2.57",
3
+ "version": "2.2.58",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",