@fy-/fws-vue 2.2.57 → 2.2.59

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.
@@ -91,6 +91,13 @@ function handleBlur() {
91
91
  emit('blur', props.id)
92
92
  }
93
93
 
94
+ // Copy input value to clipboard
95
+ function copyToClipboard() {
96
+ if (props.modelValue) {
97
+ navigator.clipboard.writeText(props.modelValue.toString())
98
+ }
99
+ }
100
+
94
101
  const model = computed<modelValueType>({
95
102
  get: () => props.modelValue,
96
103
  set: (value) => {
@@ -155,10 +162,14 @@ defineExpose({ focus, blur, getInputRef })
155
162
  "
156
163
  class="relative"
157
164
  >
165
+ <!-- Static label for all types -->
158
166
  <label
159
167
  v-if="showLabel && label"
160
168
  :for="id"
161
169
  class="block mb-2 text-sm font-medium text-fv-neutral-900 dark:text-white"
170
+ :class="{
171
+ 'text-red-600 dark:text-red-400': checkErrors,
172
+ }"
162
173
  >
163
174
  {{ label }}
164
175
  <template v-if="type === 'range'">
@@ -166,51 +177,66 @@ defineExpose({ focus, blur, getInputRef })
166
177
  </template>
167
178
  </label>
168
179
 
169
- <input
170
- :id="id"
171
- ref="inputRef"
172
- v-model="model"
173
- :type="type === 'datepicker' ? 'date' : type === 'phone' ? 'tel' : type"
174
- :name="id"
175
- :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',
179
- }"
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
- >
180
+ <!-- Input element -->
181
+ <div class="relative">
182
+ <input
183
+ :id="id"
184
+ ref="inputRef"
185
+ v-model="model"
186
+ :type="type === 'datepicker' ? 'date' : type === 'phone' ? 'tel' : type"
187
+ :name="id"
188
+ :class="{
189
+ 'error': checkErrors,
190
+ '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',
191
+ 'p-2.5': type !== 'range',
192
+ 'focus:border-fv-primary-500 dark:focus:border-fv-primary-500': !checkErrors,
193
+ 'focus:ring-2 focus:ring-fv-primary-300 dark:focus:ring-fv-primary-800 focus:ring-opacity-50': type !== 'range',
194
+ 'w-full h-3 bg-fv-neutral-200 rounded-lg appearance-none cursor-pointer dark:bg-fv-neutral-700': type === 'range',
195
+ 'pr-10': copyButton,
196
+ }"
197
+ :autocomplete="autocomplete"
198
+ :min="type === 'range' ? minRange : undefined"
199
+ :max="type === 'range' ? maxRange : undefined"
200
+ :placeholder="placeholder"
201
+ :disabled="disabled"
202
+ :aria-describedby="help ? `${id}-help` : undefined"
203
+ :required="req"
204
+ :aria-invalid="checkErrors ? 'true' : 'false'"
205
+ @focus="handleFocus"
206
+ @blur="handleBlur"
207
+ >
208
+
209
+ <!-- Copy button -->
210
+ <button
211
+ v-if="copyButton && model"
212
+ type="button"
213
+ aria-label="Copy to clipboard"
214
+ 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"
215
+ @click="copyToClipboard"
216
+ >
217
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
218
+ <path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
219
+ <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" />
220
+ </svg>
221
+ </button>
222
+ </div>
191
223
 
192
224
  <!-- Range Input Extra Labels -->
193
225
  <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"
206
- >
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"
211
- >
212
- Max ({{ maxRange }})
213
- </span>
226
+ <div class="relative pt-6 mt-2">
227
+ <div class="flex justify-between gap-1 text-xs text-fv-neutral-500 dark:text-fv-neutral-400 px-1">
228
+ <span>{{ minRange }}</span>
229
+ <span>{{ ((maxRange - minRange) / 3 + minRange).toFixed(0) }}</span>
230
+ <span>{{ (((maxRange - minRange) / 3) * 2 + minRange).toFixed(0) }}</span>
231
+ <span>{{ maxRange }}</span>
232
+ </div>
233
+ <div class="w-full h-1 bg-fv-neutral-200 dark:bg-fv-neutral-700 rounded-full mt-1">
234
+ <div
235
+ class="h-full bg-fv-primary-500 dark:bg-fv-primary-600 rounded-full"
236
+ :style="{ width: `${((model as number || minRange) - minRange) / (maxRange - minRange) * 100}%` }"
237
+ />
238
+ </div>
239
+ </div>
214
240
  </template>
215
241
  </div>
216
242
 
@@ -261,16 +287,17 @@ defineExpose({ focus, blur, getInputRef })
261
287
  :required="req"
262
288
  :aria-invalid="checkErrors ? 'true' : 'false'"
263
289
  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
290
+ border border-fv-neutral-300 focus:ring-2 focus:ring-fv-primary-300 focus:border-fv-primary-500
265
291
  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"
292
+ dark:text-white dark:focus:ring-fv-primary-800 dark:focus:border-fv-primary-500
293
+ transition-colors duration-200 shadow-sm"
267
294
  @focus="handleFocus"
268
295
  @blur="handleBlur"
269
296
  />
270
297
  </div>
271
298
  <div
272
299
  v-if="dpOptions.counterMax && model"
273
- class="text-sm text-fv-neutral-500 dark:text-fv-neutral-400"
300
+ class="text-sm text-fv-neutral-500 dark:text-fv-neutral-400 mt-1 text-right"
274
301
  :class="{
275
302
  'text-red-500 dark:text-red-300':
276
303
  model?.toString().length > dpOptions.counterMax,
@@ -304,16 +331,16 @@ defineExpose({ focus, blur, getInputRef })
304
331
  :required="req"
305
332
  :aria-invalid="checkErrors ? 'true' : 'false'"
306
333
  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"
334
+ rounded-lg border border-fv-neutral-300 focus:ring-2 focus:ring-fv-primary-300 focus:border-fv-primary-500
335
+ dark:bg-fv-neutral-700 dark:border-fv-neutral-600 dark:placeholder-fv-neutral-400
336
+ dark:text-white dark:focus:ring-fv-primary-800 dark:focus:border-fv-primary-500
337
+ transition-colors duration-200 shadow-sm min-h-[100px]"
311
338
  @focus="handleFocus"
312
339
  @blur="handleBlur"
313
340
  />
314
341
  <div
315
342
  v-if="dpOptions.counterMax && model"
316
- class="text-sm text-fv-neutral-500 dark:text-fv-neutral-400"
343
+ class="text-sm text-fv-neutral-500 dark:text-fv-neutral-400 mt-1 text-right"
317
344
  :class="{
318
345
  'text-red-500 dark:text-red-300':
319
346
  model?.toString().length > dpOptions.counterMax,
@@ -332,34 +359,42 @@ defineExpose({ focus, blur, getInputRef })
332
359
  >
333
360
  {{ label }}
334
361
  </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]"
362
+ <div class="relative">
363
+ <select
364
+ :id="id"
365
+ ref="inputRef"
366
+ v-model="model"
367
+ :name="id"
368
+ :disabled="disabled"
369
+ :aria-describedby="help ? `${id}-help` : undefined"
370
+ :required="req"
371
+ :class="{
372
+ error: checkErrors,
373
+ }"
374
+ :aria-invalid="checkErrors ? 'true' : 'false'"
375
+ class="appearance-none bg-fv-neutral-50 border border-fv-neutral-300 text-fv-neutral-900 text-sm
376
+ rounded-lg focus:ring-2 focus:ring-fv-primary-300 focus:border-fv-primary-500
377
+ block w-full p-2.5 dark:bg-fv-neutral-700 dark:border-fv-neutral-600
378
+ dark:placeholder-fv-neutral-400 dark:text-white dark:focus:ring-fv-primary-800
379
+ dark:focus:border-fv-primary-500 shadow-sm transition-colors duration-200 pr-10"
380
+ @focus="handleFocus"
381
+ @blur="handleBlur"
359
382
  >
360
- {{ opt[1] }}
361
- </option>
362
- </select>
383
+ <option
384
+ v-for="opt in options"
385
+ :key="opt[0]?.toString()"
386
+ :value="opt[0]"
387
+ >
388
+ {{ opt[1] }}
389
+ </option>
390
+ </select>
391
+ <!-- Dropdown arrow icon -->
392
+ <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">
393
+ <svg class="h-4 w-4 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
394
+ <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" />
395
+ </svg>
396
+ </div>
397
+ </div>
363
398
  </div>
364
399
  </div>
365
400
  </template>
@@ -367,9 +402,10 @@ defineExpose({ focus, blur, getInputRef })
367
402
  <!-- TOGGLE (switch) -->
368
403
  <template v-else-if="type === 'toggle'">
369
404
  <label
370
- class="inline-flex items-center mb-5 cursor-pointer"
405
+ class="inline-flex items-center mb-5 cursor-pointer group"
371
406
  :class="{
372
- error: checkErrors,
407
+ 'error': checkErrors,
408
+ 'opacity-70 cursor-not-allowed': disabled,
373
409
  }"
374
410
  >
375
411
  <input
@@ -394,7 +430,7 @@ defineExpose({ focus, blur, getInputRef })
394
430
  after:content-[''] after:absolute after:top-[2px] after:start-[2px]
395
431
  after:bg-white after:border-fv-neutral-300 after:border after:rounded-full
396
432
  after:w-5 after:h-5 after:transition-all dark:border-fv-neutral-600
397
- peer-checked:bg-fv-primary-600"
433
+ peer-checked:bg-fv-primary-600 shadow-sm group-hover:shadow-md transition-all duration-200"
398
434
  />
399
435
  <span
400
436
  class="ms-3 text-sm font-medium text-fv-neutral-900 dark:text-fv-neutral-300"
@@ -414,14 +450,16 @@ defineExpose({ focus, blur, getInputRef })
414
450
 
415
451
  <!-- CHECKBOX / RADIO -->
416
452
  <template v-else-if="type === 'checkbox' || type === 'radio'">
417
- <div class="flex mb-4">
453
+ <div class="flex mb-4" :class="{ 'opacity-70 cursor-not-allowed': disabled }">
418
454
  <div class="flex items-center h-5">
419
455
  <input
420
456
  :id="id"
421
457
  ref="inputRef"
422
458
  v-model="modelCheckbox"
423
459
  :class="{
424
- error: checkErrors,
460
+ 'error': checkErrors,
461
+ 'cursor-not-allowed': disabled,
462
+ 'cursor-pointer': !disabled,
425
463
  }"
426
464
  :aria-describedby="help ? `${id}-help` : undefined"
427
465
  :type="type"
@@ -432,7 +470,8 @@ defineExpose({ focus, blur, getInputRef })
432
470
  class="w-4 h-4 text-fv-primary-600 bg-fv-neutral-100 border-fv-neutral-300
433
471
  rounded focus:ring-fv-primary-500 dark:focus:ring-fv-primary-600
434
472
  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"
473
+ focus:ring-2 dark:bg-fv-neutral-700 dark:border-fv-neutral-600
474
+ transition-colors duration-200 shadow-sm"
436
475
  @focus="handleFocus"
437
476
  @blur="handleBlur"
438
477
  >
@@ -440,7 +479,8 @@ defineExpose({ focus, blur, getInputRef })
440
479
  <div class="ms-2 text-sm">
441
480
  <label
442
481
  :for="id"
443
- class="font-medium text-fv-neutral-900 dark:text-fv-neutral-300"
482
+ class="font-medium text-fv-neutral-900 dark:text-fv-neutral-300 cursor-pointer"
483
+ :class="{ 'cursor-not-allowed': disabled }"
444
484
  >
445
485
  {{ label }}
446
486
  </label>
@@ -458,10 +498,13 @@ defineExpose({ focus, blur, getInputRef })
458
498
  <!-- Error message -->
459
499
  <p
460
500
  v-if="checkErrors"
461
- class="mt-0.5 text-sm text-red-600 dark:text-red-300"
501
+ class="mt-0.5 text-sm text-red-600 dark:text-red-300 flex items-center"
462
502
  role="alert"
463
503
  aria-live="assertive"
464
504
  >
505
+ <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">
506
+ <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" />
507
+ </svg>
465
508
  {{ checkErrors }}
466
509
  </p>
467
510
 
@@ -481,7 +524,59 @@ input,
481
524
  textarea,
482
525
  select {
483
526
  &.error {
484
- @apply border-red-500 dark:border-red-400;
527
+ @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
528
  }
486
529
  }
530
+
531
+ /* Range input styling */
532
+ input[type="range"] {
533
+ @apply appearance-none bg-transparent;
534
+ }
535
+
536
+ input[type="range"]::-webkit-slider-thumb {
537
+ @apply appearance-none w-4 h-4 rounded-full bg-fv-primary-500 dark:bg-fv-primary-600 cursor-pointer border-0 shadow-md;
538
+ margin-top: -0.5rem;
539
+ }
540
+
541
+ input[type="range"]::-moz-range-thumb {
542
+ @apply w-4 h-4 rounded-full bg-fv-primary-500 dark:bg-fv-primary-600 cursor-pointer border-0 shadow-md;
543
+ }
544
+
545
+ input[type="range"]:focus::-webkit-slider-thumb {
546
+ @apply ring-2 ring-fv-primary-300 dark:ring-fv-primary-800;
547
+ }
548
+
549
+ input[type="range"]:focus::-moz-range-thumb {
550
+ @apply ring-2 ring-fv-primary-300 dark:ring-fv-primary-800;
551
+ }
552
+
553
+ /* Textarea auto-grow */
554
+ .grow-wrap {
555
+ @apply grid;
556
+ }
557
+ .grow-wrap::after {
558
+ content: attr(data-replicated-value) " ";
559
+ white-space: pre-wrap;
560
+ visibility: hidden;
561
+ }
562
+ .grow-wrap > textarea {
563
+ resize: none;
564
+ overflow: hidden;
565
+ }
566
+ .grow-wrap > textarea,
567
+ .grow-wrap::after {
568
+ grid-area: 1 / 1 / 2 / 2;
569
+ }
570
+
571
+ /* Add smooth transitions */
572
+ input, select, textarea, input[type="range"]::-webkit-slider-thumb, input[type="range"]::-moz-range-thumb {
573
+ @apply transition-all duration-200;
574
+ }
575
+
576
+ /* Placeholder styling */
577
+ input::placeholder,
578
+ textarea::placeholder,
579
+ select::placeholder {
580
+ @apply text-fv-neutral-400 dark:text-fv-neutral-500;
581
+ }
487
582
  </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-200 text-blue-800
442
+ dark:bg-blue-700 dark:text-blue-50
443
+ ring-1 ring-blue-400 dark:ring-blue-600;
371
444
  }
372
- .tag.purple {
373
- @apply bg-purple-400 dark:bg-purple-800;
445
+
446
+ .tag-red, .tag-error {
447
+ @apply bg-red-200 text-red-800
448
+ dark:bg-red-700 dark:text-red-50
449
+ ring-1 ring-red-400 dark:ring-red-600;
450
+ }
451
+
452
+ .tag-green {
453
+ @apply bg-green-200 text-green-800
454
+ dark:bg-green-700 dark:text-green-50
455
+ ring-1 ring-green-400 dark:ring-green-600;
456
+ }
457
+
458
+ .tag-purple {
459
+ @apply bg-purple-200 text-purple-800
460
+ dark:bg-purple-700 dark:text-purple-50
461
+ ring-1 ring-purple-400 dark:ring-purple-600;
374
462
  }
375
- .tag.orange {
376
- @apply bg-orange-400 dark:bg-orange-800;
463
+
464
+ .tag-orange {
465
+ @apply bg-orange-200 text-orange-800
466
+ dark:bg-orange-700 dark:text-orange-50
467
+ ring-1 ring-orange-400 dark:ring-orange-600;
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-200 text-fv-neutral-800
472
+ dark:bg-fv-neutral-600 dark:text-fv-neutral-50
473
+ ring-1 ring-fv-neutral-400 dark:ring-fv-neutral-500;
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.59",
4
4
  "author": "Florian 'Fy' Gasquez <m@fy.to>",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/fy-to/FWJS#readme",