@fy-/fws-vue 2.1.43 → 2.1.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/ui/DefaultNotif.vue +94 -35
- package/components/ui/DefaultTagInput.vue +220 -62
- package/package.json +1 -1
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { Component } from 'vue'
|
|
3
|
-
|
|
4
3
|
import {
|
|
5
4
|
CheckCircleIcon,
|
|
6
5
|
ExclamationTriangleIcon,
|
|
7
6
|
LightBulbIcon,
|
|
7
|
+
SparklesIcon,
|
|
8
8
|
} from '@heroicons/vue/24/solid'
|
|
9
9
|
import { onMounted, onUnmounted, ref } from 'vue'
|
|
10
10
|
import { useEventBus } from '../../composables/event-bus'
|
|
11
11
|
import ScaleTransition from './transitions/ScaleTransition.vue'
|
|
12
12
|
|
|
13
|
+
/** Notification interface */
|
|
13
14
|
interface NotifProps {
|
|
14
15
|
imgSrc?: string
|
|
15
16
|
imgIcon?: Component
|
|
@@ -18,56 +19,97 @@ interface NotifProps {
|
|
|
18
19
|
ctaText?: string
|
|
19
20
|
ctaLink?: string
|
|
20
21
|
ctaAction?: () => void
|
|
21
|
-
|
|
22
|
+
/** Add your new 'secret' type */
|
|
23
|
+
type?: 'info' | 'warning' | 'success' | 'secret'
|
|
24
|
+
/** Notification timeout in milliseconds */
|
|
22
25
|
time?: number
|
|
23
26
|
}
|
|
27
|
+
|
|
28
|
+
/** Our global event bus (replace with your own logic if needed) */
|
|
24
29
|
const eventBus = useEventBus()
|
|
30
|
+
|
|
31
|
+
/** Current displayed notification */
|
|
25
32
|
const currentNotif = ref<NotifProps | null>(null)
|
|
26
|
-
|
|
33
|
+
|
|
34
|
+
/** Progress percentage (0 to 100) for the notification’s life */
|
|
35
|
+
const progress = ref(0)
|
|
36
|
+
|
|
37
|
+
/** References to setTimeout / setInterval so we can clear them properly */
|
|
38
|
+
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
|
39
|
+
let progressInterval: ReturnType<typeof setInterval> | null = null
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Primary logic when a 'SendNotif' event is called.
|
|
43
|
+
* - Clears any existing notification first
|
|
44
|
+
* - Sets up the new notification
|
|
45
|
+
* - Starts a progress bar
|
|
46
|
+
*/
|
|
27
47
|
function onCall(data: NotifProps) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (data.imgIcon === undefined) {
|
|
33
|
-
if (data.type === 'info') {
|
|
34
|
-
actualIcon.value = LightBulbIcon
|
|
35
|
-
}
|
|
36
|
-
else if (data.type === 'warning') {
|
|
37
|
-
actualIcon.value = ExclamationTriangleIcon
|
|
38
|
-
}
|
|
39
|
-
else if (data.type === 'success') {
|
|
40
|
-
actualIcon.value = CheckCircleIcon
|
|
41
|
-
}
|
|
42
|
-
}
|
|
48
|
+
// If there's an existing notification, remove it first
|
|
49
|
+
hideNotif()
|
|
50
|
+
|
|
51
|
+
// Ensure a minimum of 1s if time is too short or undefined
|
|
43
52
|
if (!data.time || data.time < 1000) {
|
|
44
53
|
data.time = 5000
|
|
45
54
|
}
|
|
46
55
|
|
|
56
|
+
// Automatically compute an icon if none is provided
|
|
57
|
+
if (!data.imgIcon) {
|
|
58
|
+
if (data.type === 'info') data.imgIcon = LightBulbIcon
|
|
59
|
+
else if (data.type === 'warning') data.imgIcon = ExclamationTriangleIcon
|
|
60
|
+
else if (data.type === 'success') data.imgIcon = CheckCircleIcon
|
|
61
|
+
else if (data.type === 'secret') data.imgIcon = SparklesIcon
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Set the new notification
|
|
47
65
|
currentNotif.value = {
|
|
48
|
-
|
|
49
|
-
imgIcon: actualIcon.value,
|
|
50
|
-
title: data.title,
|
|
51
|
-
content: data.content,
|
|
52
|
-
ctaText: data.ctaText,
|
|
53
|
-
ctaLink: data.ctaLink,
|
|
54
|
-
time: data.time,
|
|
55
|
-
type: data.type,
|
|
56
|
-
ctaAction: data.ctaAction,
|
|
66
|
+
...data,
|
|
57
67
|
}
|
|
58
68
|
|
|
59
|
-
|
|
69
|
+
// (A) Hide the notification after the specified time
|
|
70
|
+
hideTimeout = setTimeout(() => hideNotif(), data.time)
|
|
71
|
+
|
|
72
|
+
// (B) Animate the progress bar from 0 to 100% within that time
|
|
73
|
+
progress.value = 0
|
|
74
|
+
progressInterval = setInterval(() => {
|
|
75
|
+
if (currentNotif.value && data.time) {
|
|
76
|
+
// update progress based on a 100ms tick
|
|
77
|
+
progress.value += (100 / (data.time / 100))
|
|
78
|
+
// if progress hits or exceeds 100, hide
|
|
79
|
+
if (progress.value >= 100) {
|
|
80
|
+
hideNotif()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}, 100)
|
|
60
84
|
}
|
|
61
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Clears everything related to the current notification
|
|
88
|
+
*/
|
|
62
89
|
function hideNotif() {
|
|
63
90
|
currentNotif.value = null
|
|
64
|
-
|
|
65
|
-
|
|
91
|
+
progress.value = 0
|
|
92
|
+
|
|
93
|
+
if (hideTimeout) {
|
|
94
|
+
clearTimeout(hideTimeout)
|
|
95
|
+
hideTimeout = null
|
|
96
|
+
}
|
|
97
|
+
if (progressInterval) {
|
|
98
|
+
clearInterval(progressInterval)
|
|
99
|
+
progressInterval = null
|
|
66
100
|
}
|
|
67
101
|
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Setup: Listen to the global event bus
|
|
105
|
+
*/
|
|
68
106
|
onMounted(() => {
|
|
69
107
|
eventBus.on('SendNotif', onCall)
|
|
70
108
|
})
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Cleanup: remove event listeners
|
|
112
|
+
*/
|
|
71
113
|
onUnmounted(() => {
|
|
72
114
|
eventBus.off('SendNotif', onCall)
|
|
73
115
|
})
|
|
@@ -78,17 +120,20 @@ onUnmounted(() => {
|
|
|
78
120
|
<div
|
|
79
121
|
v-if="currentNotif !== null"
|
|
80
122
|
id="base-notif"
|
|
81
|
-
class="p-2 mb-4 fixed bottom-4 right-8 !z-[2000] bg-fv-neutral-50/[.6] dark:bg-neutral-800/[.6]"
|
|
123
|
+
class="p-2 mb-4 fixed bottom-4 right-8 !z-[2000] bg-fv-neutral-50/[.6] dark:bg-neutral-800/[.6] rounded-lg border"
|
|
82
124
|
role="alert"
|
|
83
125
|
:class="{
|
|
84
|
-
'text-fv-neutral-800 border
|
|
126
|
+
'text-fv-neutral-800 border-fv-neutral-300 dark:text-fv-neutral-400 dark:border-fv-neutral-600':
|
|
85
127
|
currentNotif.type === 'info',
|
|
86
|
-
'text-red-800 border
|
|
128
|
+
'text-red-800 border-red-300 dark:text-red-300 dark:border-red-800':
|
|
87
129
|
currentNotif.type === 'warning',
|
|
88
|
-
'text-green-800 border
|
|
130
|
+
'text-green-800 border-green-300 dark:text-green-300 dark:border-green-800':
|
|
89
131
|
currentNotif.type === 'success',
|
|
132
|
+
'text-fuchsia-800 border-fuchsia-300 dark:text-fuchsia-300 dark:border-fuchsia-800':
|
|
133
|
+
currentNotif.type === 'secret',
|
|
90
134
|
}"
|
|
91
135
|
>
|
|
136
|
+
<!-- Title + icon or image -->
|
|
92
137
|
<div class="flex items-center gap-2">
|
|
93
138
|
<img
|
|
94
139
|
v-if="currentNotif.imgSrc"
|
|
@@ -103,11 +148,24 @@ onUnmounted(() => {
|
|
|
103
148
|
/>
|
|
104
149
|
<h3 class="text-lg font-medium" v-text="currentNotif.title" />
|
|
105
150
|
</div>
|
|
151
|
+
|
|
152
|
+
<!-- Optional content -->
|
|
106
153
|
<div
|
|
107
154
|
v-if="currentNotif.content"
|
|
108
|
-
class="mt-
|
|
155
|
+
class="mt-2 text-sm"
|
|
109
156
|
v-text="currentNotif.content"
|
|
110
157
|
/>
|
|
158
|
+
|
|
159
|
+
<!-- Progress bar (3px) -->
|
|
160
|
+
<div class="relative mt-3 h-[3px] bg-gray-200 rounded-full overflow-hidden">
|
|
161
|
+
<!-- We re-use text color (text-*) as background or define a custom color -->
|
|
162
|
+
<div
|
|
163
|
+
class="absolute left-0 top-0 h-full bg-current transition-[width]"
|
|
164
|
+
:style="{ width: `${progress}%` }"
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<!-- CTA row (if you need more buttons, just extend it) -->
|
|
111
169
|
<div class="flex justify-end gap-2 pt-3">
|
|
112
170
|
<button
|
|
113
171
|
type="button"
|
|
@@ -115,6 +173,7 @@ onUnmounted(() => {
|
|
|
115
173
|
aria-label="Close"
|
|
116
174
|
@click="hideNotif"
|
|
117
175
|
>
|
|
176
|
+
<!-- i18n example, or plain text like "Dismiss" -->
|
|
118
177
|
{{ $t("dismiss_cta") }}
|
|
119
178
|
</button>
|
|
120
179
|
</div>
|
|
@@ -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: '
|
|
97
|
+
title: 'Tags copied!',
|
|
51
98
|
type: 'success',
|
|
52
99
|
time: 2500,
|
|
53
100
|
})
|
|
54
101
|
}
|
|
55
102
|
|
|
56
|
-
|
|
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(
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
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="
|
|
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="
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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>
|