ketekny-ui-kit 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/dist/ketekny-ui-kit.css +1 -0
- package/dist/ketekny-ui-kit.js +2607 -0
- package/dist/ketekny-ui-kit.umd.cjs +9 -0
- package/index.js +61 -0
- package/package.json +45 -0
- package/src/directives/v-tooltip.js +286 -0
- package/src/layout/kAppFooter.vue +20 -0
- package/src/layout/kAppHeader.vue +69 -0
- package/src/layout/kAppMain.vue +18 -0
- package/src/layout/kHero.vue +11 -0
- package/src/plugins/alertPlugin.js +23 -0
- package/src/plugins/confirmPlugin.js +16 -0
- package/src/plugins/inputDialogPlugin.js +27 -0
- package/src/plugins/toastPlugin.js +18 -0
- package/src/plugins/tooltipPlugin.js +10 -0
- package/src/style/style.css +39 -0
- package/src/ui/kAlert.vue +124 -0
- package/src/ui/kArrayList.vue +149 -0
- package/src/ui/kButton.vue +92 -0
- package/src/ui/kChip.vue +48 -0
- package/src/ui/kCode.vue +54 -0
- package/src/ui/kConfirmDialog.vue +70 -0
- package/src/ui/kDatatable.vue +156 -0
- package/src/ui/kDateSelector.vue +326 -0
- package/src/ui/kDialog.vue +100 -0
- package/src/ui/kDrawer.vue +71 -0
- package/src/ui/kEditor.vue +128 -0
- package/src/ui/kIcon.vue +50 -0
- package/src/ui/kInput.vue +200 -0
- package/src/ui/kInputDialog.vue +129 -0
- package/src/ui/kMenu.vue +51 -0
- package/src/ui/kMessage.vue +63 -0
- package/src/ui/kSearch.vue +45 -0
- package/src/ui/kSelect.vue +188 -0
- package/src/ui/kSelectButton.vue +70 -0
- package/src/ui/kSpinner.vue +38 -0
- package/src/ui/kTable.vue +90 -0
- package/src/ui/kTabs.vue +36 -0
- package/src/ui/kTags.vue +88 -0
- package/src/ui/kToast.vue +90 -0
- package/src/ui/kToggle.vue +46 -0
- package/src/ui/kToolbar.vue +84 -0
- package/src/ui/kUploader.vue +193 -0
- package/tailwind-preset.js +53 -0
- package/tailwind.config.js +66 -0
package/src/ui/kMenu.vue
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="relative inline-block text-left">
|
|
3
|
+
<div @click="toggleMenu">
|
|
4
|
+
<slot name="trigger"></slot>
|
|
5
|
+
</div>
|
|
6
|
+
<div
|
|
7
|
+
v-if="isOpen"
|
|
8
|
+
class="absolute right-0 z-10 w-56 mt-2 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5"
|
|
9
|
+
>
|
|
10
|
+
<slot name="items"></slot>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<script>
|
|
16
|
+
export default {
|
|
17
|
+
name: 'kMenu',
|
|
18
|
+
data() {
|
|
19
|
+
return {
|
|
20
|
+
isOpen: false,
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
methods: {
|
|
24
|
+
toggleMenu() {
|
|
25
|
+
this.isOpen = !this.isOpen
|
|
26
|
+
},
|
|
27
|
+
closeMenu() {
|
|
28
|
+
this.isOpen = false
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
mounted() {
|
|
32
|
+
document.addEventListener('click', this.handleClickOutside)
|
|
33
|
+
},
|
|
34
|
+
beforeUnmount() {
|
|
35
|
+
document.removeEventListener('click', this.handleClickOutside)
|
|
36
|
+
},
|
|
37
|
+
methods: {
|
|
38
|
+
toggleMenu() {
|
|
39
|
+
this.isOpen = !this.isOpen
|
|
40
|
+
},
|
|
41
|
+
handleClickOutside(event) {
|
|
42
|
+
if (!this.$el.contains(event.target)) {
|
|
43
|
+
this.closeMenu()
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
closeMenu() {
|
|
47
|
+
this.isOpen = false
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="flex rounded-lg overflow-hidden min-h-[50px] border-l-8"
|
|
4
|
+
:class="['border-semantic-' + type + '-border', 'bg-semantic-' + type + '-bg']"
|
|
5
|
+
>
|
|
6
|
+
<!-- Icon Column -->
|
|
7
|
+
<div class="flex items-start justify-center h-full px-4" :class="iconColor">
|
|
8
|
+
<component :is="icon" class="mt-3" />
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<!-- Text Column -->
|
|
12
|
+
<div class="flex flex-col justify-center flex-1 py-2 pr-4">
|
|
13
|
+
<div v-if="title" :class="['mb-2 text-xl font-medium', textClass]">{{ title }}</div>
|
|
14
|
+
<p :class="['p-0 m-0 leading-normal', textClass]">
|
|
15
|
+
<slot></slot>
|
|
16
|
+
</p>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
</template>
|
|
20
|
+
|
|
21
|
+
<script setup>
|
|
22
|
+
import {
|
|
23
|
+
Info,
|
|
24
|
+
TriangleAlert,
|
|
25
|
+
OctagonX,
|
|
26
|
+
CircleCheckBig,
|
|
27
|
+
} from 'lucide-vue-next'
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<script>
|
|
31
|
+
export default {
|
|
32
|
+
props: {
|
|
33
|
+
type: {
|
|
34
|
+
type: String,
|
|
35
|
+
default: 'warning', // 'info', 'success', 'danger'
|
|
36
|
+
validator: val => ['info', 'success', 'warning', 'error'].includes(val),
|
|
37
|
+
},
|
|
38
|
+
title: { type: String, default: '' },
|
|
39
|
+
},
|
|
40
|
+
computed: {
|
|
41
|
+
bgClass() {
|
|
42
|
+
return `bg-semantic-${this.type}-bg`
|
|
43
|
+
},
|
|
44
|
+
borderClass() {
|
|
45
|
+
return `border-semantic-${this.type}-border`
|
|
46
|
+
},
|
|
47
|
+
textClass() {
|
|
48
|
+
return `text-semantic-${this.type}-text`
|
|
49
|
+
},
|
|
50
|
+
iconColor() {
|
|
51
|
+
return `text-semantic-${this.type}-text`
|
|
52
|
+
},
|
|
53
|
+
icon() {
|
|
54
|
+
return {
|
|
55
|
+
info: Info,
|
|
56
|
+
warning: TriangleAlert,
|
|
57
|
+
error: OctagonX,
|
|
58
|
+
success: CircleCheckBig,
|
|
59
|
+
}[this.type]
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="relative w-full">
|
|
3
|
+
<input type="text" v-model="localValue" @input="onInput" :class="defaultStyle" :placeholder="placeholder" />
|
|
4
|
+
<Search class="absolute w-4 h-4 text-gray-400 -translate-y-1/2 pointer-events-none right-3 top-1/2" />
|
|
5
|
+
</div>
|
|
6
|
+
</template>
|
|
7
|
+
|
|
8
|
+
<script setup>
|
|
9
|
+
import { Search } from "lucide-vue-next";
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<script>
|
|
13
|
+
export default {
|
|
14
|
+
name: "kSearch",
|
|
15
|
+
props: {
|
|
16
|
+
modelValue: { type: String, default: "" },
|
|
17
|
+
debounce: { type: [String, Number], default: 300 },
|
|
18
|
+
placeholder: { type: String, default: "Αναζήτηση..." },
|
|
19
|
+
},
|
|
20
|
+
data() {
|
|
21
|
+
return {
|
|
22
|
+
defaultStyle: "w-full px-3 py-2 border rounded-lg transition shadow-sm focus:outline-none text-gray-700 focus:ring-0 focus:border-green-500 bg-white placeholder-gray-400",
|
|
23
|
+
|
|
24
|
+
localValue: this.modelValue,
|
|
25
|
+
debounceTimeout: null,
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
watch: {
|
|
29
|
+
modelValue(newVal) {
|
|
30
|
+
this.localValue = newVal;
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
methods: {
|
|
34
|
+
onInput() {
|
|
35
|
+
clearTimeout(this.debounceTimeout);
|
|
36
|
+
this.debounceTimeout = setTimeout(() => {
|
|
37
|
+
this.$emit("update:modelValue", this.localValue);
|
|
38
|
+
}, this.debounce);
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
beforeUnmount() {
|
|
42
|
+
clearTimeout(this.debounceTimeout);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
</script>
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="relative w-full">
|
|
3
|
+
<!-- Label -->
|
|
4
|
+
<label :for="computedId" class="inputLabel" :class="hasError ? 'text-red-500' : 'text-gray-700'">
|
|
5
|
+
{{ label }}
|
|
6
|
+
</label>
|
|
7
|
+
|
|
8
|
+
<!-- Input Trigger -->
|
|
9
|
+
<div
|
|
10
|
+
ref="trigger"
|
|
11
|
+
@click="toggleDropdown"
|
|
12
|
+
class="relative w-full px-3 py-2 text-gray-700 bg-white border rounded-lg cursor-pointer trigger-box"
|
|
13
|
+
:class="[defaultStyle, hasError ? errorStyle : '', disabled ? disabledStyle : '']"
|
|
14
|
+
:aria-haspopup="'listbox'"
|
|
15
|
+
:aria-expanded="isOpen.toString()"
|
|
16
|
+
:aria-disabled="disabled.toString()"
|
|
17
|
+
:role="'combobox'"
|
|
18
|
+
>
|
|
19
|
+
<span class="block pr-10 truncate">{{ selectedLabel || placeholder }}</span>
|
|
20
|
+
|
|
21
|
+
<!-- Clear button -->
|
|
22
|
+
<button v-if="selectedValue && !disabled" class="absolute text-gray-400 -translate-y-1/2 right-8 top-1/2 hover:text-red-500" @click.stop="clearSelection" aria-label="Clear selection">
|
|
23
|
+
<X class="w-5 h-5 mr-2" />
|
|
24
|
+
</button>
|
|
25
|
+
|
|
26
|
+
<!-- Dropdown icon -->
|
|
27
|
+
<span class="absolute text-gray-400 -translate-y-1/2 pointer-events-none right-3 top-1/2">
|
|
28
|
+
<ChevronDown class="w-5 h-5" v-if="!isOpen" />
|
|
29
|
+
<ChevronUp class="w-5 h-5" v-else />
|
|
30
|
+
</span>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<!-- Dropdown Menu -->
|
|
34
|
+
<Teleport to="body">
|
|
35
|
+
<div
|
|
36
|
+
v-if="isOpen"
|
|
37
|
+
ref="dropdown"
|
|
38
|
+
class="absolute z-[9999] mt-1 overflow-auto bg-white border border-gray-200 rounded-lg shadow-lg"
|
|
39
|
+
:class="dropdownHeight"
|
|
40
|
+
:style="dropdownPositionStyle"
|
|
41
|
+
role="listbox"
|
|
42
|
+
>
|
|
43
|
+
<!-- Search -->
|
|
44
|
+
<div class="sticky top-0 z-10 pt-2 pb-2 mx-2 bg-white">
|
|
45
|
+
<input
|
|
46
|
+
type="text"
|
|
47
|
+
v-model="searchQuery"
|
|
48
|
+
placeholder="Αναζήτηση..."
|
|
49
|
+
class="w-full px-3 py-2 border border-gray-200 rounded-md focus:outline-none"
|
|
50
|
+
role="searchbox"
|
|
51
|
+
aria-label="Search options"
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<!-- Filtered Options -->
|
|
56
|
+
<div class="flex flex-col gap-[2px] p-2">
|
|
57
|
+
<div
|
|
58
|
+
v-for="option in filteredOptions"
|
|
59
|
+
:key="option[optionValue]"
|
|
60
|
+
@click="selectOption(option)"
|
|
61
|
+
class="px-3 py-2 rounded cursor-pointer"
|
|
62
|
+
:class="option[optionValue] === selectedValue ? 'bg-primary text-white' : 'hover:bg-blue-100'"
|
|
63
|
+
role="option"
|
|
64
|
+
:aria-selected="(option[optionValue] === selectedValue).toString()"
|
|
65
|
+
>
|
|
66
|
+
{{ option[optionLabel] }}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div v-if="filteredOptions.length === 0" class="px-3 py-2 text-gray-400">Δεν βρέθηκαν αποτελέσματα</div>
|
|
71
|
+
</div>
|
|
72
|
+
</Teleport>
|
|
73
|
+
|
|
74
|
+
<!-- Error -->
|
|
75
|
+
<div class="mt-1 text-red-500" v-if="hasError && error !== true && error !== ''">
|
|
76
|
+
{{ error }}
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<!-- Info -->
|
|
80
|
+
<div class="mt-1 text-gray-500" v-if="info">
|
|
81
|
+
{{ info }}
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</template>
|
|
85
|
+
|
|
86
|
+
<script setup>
|
|
87
|
+
import { X, ChevronDown, ChevronUp } from "lucide-vue-next";
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<script>
|
|
91
|
+
export default {
|
|
92
|
+
name: "kSelect",
|
|
93
|
+
props: {
|
|
94
|
+
options: { type: Array, required: true },
|
|
95
|
+
modelValue: [String, Number],
|
|
96
|
+
optionValue: { type: String, default: "value" },
|
|
97
|
+
optionLabel: { type: String, default: "label" },
|
|
98
|
+
label: String,
|
|
99
|
+
info: String,
|
|
100
|
+
error: [String, Boolean],
|
|
101
|
+
disabled: Boolean,
|
|
102
|
+
placeholder: { type: String, default: "Επιλέξτε μία τιμή" },
|
|
103
|
+
id: { type: String, default: "" },
|
|
104
|
+
dropdownHeight: { type: String, default: "max-h-60" },
|
|
105
|
+
},
|
|
106
|
+
data() {
|
|
107
|
+
return {
|
|
108
|
+
isOpen: false,
|
|
109
|
+
searchQuery: "",
|
|
110
|
+
dropdownPositionStyle: {},
|
|
111
|
+
defaultStyle: "w-full px-3 py-2 border rounded-lg transition shadow-sm focus:outline-none text-gray-700 focus:ring-0 focus:border-green-500 bg-white placeholder-gray-400",
|
|
112
|
+
errorStyle: "border-red-500 focus:ring focus:ring-red-300",
|
|
113
|
+
disabledStyle: "!bg-gray-100 !text-gray-400 !cursor-not-allowed",
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
computed: {
|
|
117
|
+
computedId() {
|
|
118
|
+
return this.id || `select-${Math.random().toString(36).substr(2, 9)}`;
|
|
119
|
+
},
|
|
120
|
+
selectedValue() {
|
|
121
|
+
return this.modelValue;
|
|
122
|
+
},
|
|
123
|
+
selectedLabel() {
|
|
124
|
+
const match = this.options.find((o) => o[this.optionValue] === this.modelValue);
|
|
125
|
+
return match ? match[this.optionLabel] : "";
|
|
126
|
+
},
|
|
127
|
+
filteredOptions() {
|
|
128
|
+
const q = this.searchQuery.trim().toLowerCase();
|
|
129
|
+
return this.options.filter((o) => o[this.optionLabel].toLowerCase().includes(q));
|
|
130
|
+
},
|
|
131
|
+
hasError() {
|
|
132
|
+
return this.error != null && this.error !== false;
|
|
133
|
+
},
|
|
134
|
+
baseInputClasses() {
|
|
135
|
+
return "transition shadow-sm focus:ring focus:ring-blue-300";
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
methods: {
|
|
139
|
+
toggleDropdown() {
|
|
140
|
+
if (this.disabled) return;
|
|
141
|
+
this.isOpen = !this.isOpen;
|
|
142
|
+
if (this.isOpen) {
|
|
143
|
+
this.$nextTick(() => this.getDropdownPosition());
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
selectOption(option) {
|
|
147
|
+
this.$emit("update:modelValue", option[this.optionValue]);
|
|
148
|
+
this.searchQuery = "";
|
|
149
|
+
this.isOpen = false;
|
|
150
|
+
},
|
|
151
|
+
clearSelection() {
|
|
152
|
+
this.$emit("update:modelValue", null);
|
|
153
|
+
this.searchQuery = "";
|
|
154
|
+
this.isOpen = false;
|
|
155
|
+
},
|
|
156
|
+
closeOnClickOutside(e) {
|
|
157
|
+
const clickedOutside = !this.$el.contains(e.target) && !(this.$refs.dropdown && this.$refs.dropdown.contains(e.target));
|
|
158
|
+
|
|
159
|
+
if (clickedOutside) {
|
|
160
|
+
this.isOpen = false;
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
getDropdownPosition() {
|
|
165
|
+
const trigger = this.$refs.trigger;
|
|
166
|
+
if (!trigger) return;
|
|
167
|
+
|
|
168
|
+
const rect = trigger.getBoundingClientRect();
|
|
169
|
+
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
170
|
+
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
|
|
171
|
+
|
|
172
|
+
this.dropdownPositionStyle = {
|
|
173
|
+
position: "absolute",
|
|
174
|
+
top: `${rect.bottom + scrollTop}px`,
|
|
175
|
+
left: `${rect.left + scrollLeft}px`,
|
|
176
|
+
minWidth: `${rect.width}px`,
|
|
177
|
+
maxWidth: `500px`,
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
mounted() {
|
|
182
|
+
document.addEventListener("click", this.closeOnClickOutside);
|
|
183
|
+
},
|
|
184
|
+
beforeUnmount() {
|
|
185
|
+
document.removeEventListener("click", this.closeOnClickOutside);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
</script>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="inline-flex overflow-hidden border border-gray-300 rounded-md" style="width: fit-content;">
|
|
3
|
+
|
|
4
|
+
<button
|
|
5
|
+
v-for="(option, index) in options"
|
|
6
|
+
:key="option[valueKey]"
|
|
7
|
+
:class="[
|
|
8
|
+
baseClasses,
|
|
9
|
+
isSelected(option) ? activeClasses : inactiveClasses,
|
|
10
|
+
index === 0 ? 'border-r' : '',
|
|
11
|
+
index === options.length - 1 ? 'border-l' : 'border-l border-r',
|
|
12
|
+
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
|
|
13
|
+
]"
|
|
14
|
+
@click="toggle(option)"
|
|
15
|
+
:disabled="disabled"
|
|
16
|
+
type="button"
|
|
17
|
+
>
|
|
18
|
+
{{ option[labelKey] }}
|
|
19
|
+
</button>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<script>
|
|
24
|
+
export default {
|
|
25
|
+
name: "kSelectButton",
|
|
26
|
+
props: {
|
|
27
|
+
modelValue: [String, Number, Array],
|
|
28
|
+
options: { type: Array, required: true },
|
|
29
|
+
multiple: { type: Boolean, default: false },
|
|
30
|
+
valueKey: { type: String, default: "value" },
|
|
31
|
+
labelKey: { type: String, default: "label" },
|
|
32
|
+
disabled: { type: Boolean, default: false },
|
|
33
|
+
},
|
|
34
|
+
emits: ["update:modelValue"],
|
|
35
|
+
computed: {
|
|
36
|
+
baseClasses() {
|
|
37
|
+
return "px-4 py-2 text-sm font-medium border-none focus:outline-none";
|
|
38
|
+
},
|
|
39
|
+
activeClasses() {
|
|
40
|
+
return "bg-primary text-white";
|
|
41
|
+
},
|
|
42
|
+
inactiveClasses() {
|
|
43
|
+
return "bg-white text-gray-700 hover:bg-gray-100";
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
methods: {
|
|
47
|
+
isSelected(option) {
|
|
48
|
+
const val = option[this.valueKey];
|
|
49
|
+
if (this.multiple && Array.isArray(this.modelValue)) {
|
|
50
|
+
return this.modelValue.includes(val);
|
|
51
|
+
}
|
|
52
|
+
return this.modelValue === val;
|
|
53
|
+
},
|
|
54
|
+
toggle(option) {
|
|
55
|
+
if (this.disabled) return;
|
|
56
|
+
const val = option[this.valueKey];
|
|
57
|
+
|
|
58
|
+
if (this.multiple) {
|
|
59
|
+
const selected = [...(this.modelValue || [])];
|
|
60
|
+
const index = selected.indexOf(val);
|
|
61
|
+
if (index === -1) selected.push(val);
|
|
62
|
+
else selected.splice(index, 1);
|
|
63
|
+
this.$emit("update:modelValue", selected);
|
|
64
|
+
} else {
|
|
65
|
+
this.$emit("update:modelValue", this.modelValue === val ? null : val);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
</script>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex items-center justify-center w-16 space-x-1" v-if="type == 'line'">
|
|
3
|
+
<i class="ml-1 text-blue-500 pi pi-wave-pulse" />
|
|
4
|
+
<div class="w-2 h-2 bg-blue-500 rounded-full animate-fade" :style="{ animationDelay: '0s' }"></div>
|
|
5
|
+
<div class="w-2 h-2 bg-blue-500 rounded-full animate-fade" :style="{ animationDelay: '0.15s' }"></div>
|
|
6
|
+
<div class="w-2 h-2 bg-blue-500 rounded-full animate-fade" :style="{ animationDelay: '0.3s' }"></div>
|
|
7
|
+
<div class="w-2 h-2 bg-blue-500 rounded-full animate-fade" :style="{ animationDelay: '0.45s' }"></div>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<Loader class="w-6 h-6 mr-2 animate-spin" style="animation-duration: 1.5s" v-else-if="type == 'circle'" />
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<script setup>
|
|
14
|
+
import { Loader } from "lucide-vue-next";
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<script>
|
|
18
|
+
export default {
|
|
19
|
+
name: "FourDotSpinner",
|
|
20
|
+
props: { type: { type: String, default: "line" } },
|
|
21
|
+
};
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<style scoped>
|
|
25
|
+
@keyframes fade {
|
|
26
|
+
0%,
|
|
27
|
+
100% {
|
|
28
|
+
opacity: 0.2;
|
|
29
|
+
}
|
|
30
|
+
50% {
|
|
31
|
+
opacity: 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.animate-fade {
|
|
36
|
+
animation: fade 1s ease-in-out infinite;
|
|
37
|
+
}
|
|
38
|
+
</style>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div ref="tableWrapper" class="w-full">
|
|
3
|
+
<table class="table-custom">
|
|
4
|
+
<!-- Styled Thead -->
|
|
5
|
+
<thead ref="theadRef" class="hidden sm:table-header-group">
|
|
6
|
+
<slot name="head"></slot>
|
|
7
|
+
</thead>
|
|
8
|
+
|
|
9
|
+
<!-- Styled Tbody -->
|
|
10
|
+
<tbody ref="tbodyRef">
|
|
11
|
+
<slot name="body"></slot>
|
|
12
|
+
</tbody>
|
|
13
|
+
</table>
|
|
14
|
+
</div>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<script>
|
|
18
|
+
export default {
|
|
19
|
+
name: "kTable",
|
|
20
|
+
mounted() {
|
|
21
|
+
this.$nextTick(() => {
|
|
22
|
+
this.applyMobileLayout();
|
|
23
|
+
|
|
24
|
+
// Observe body slot DOM changes (dynamic rows)
|
|
25
|
+
this._observer = new MutationObserver(() => {
|
|
26
|
+
this.$nextTick(() => this.applyMobileLayout());
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
this._observer.observe(this.$refs.tbodyRef, {
|
|
30
|
+
childList: true,
|
|
31
|
+
subtree: true,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
beforeUnmount() {
|
|
36
|
+
if (this._observer) this._observer.disconnect();
|
|
37
|
+
},
|
|
38
|
+
methods: {
|
|
39
|
+
applyMobileLayout() {
|
|
40
|
+
const thead = this.$refs.theadRef;
|
|
41
|
+
if (!thead) return;
|
|
42
|
+
|
|
43
|
+
const headers = Array.from(thead.querySelectorAll("th")).map((th) => th.textContent.trim());
|
|
44
|
+
|
|
45
|
+
const tbody = this.$refs.tbodyRef;
|
|
46
|
+
const rows = tbody.querySelectorAll("tr");
|
|
47
|
+
|
|
48
|
+
rows.forEach((row) => {
|
|
49
|
+
const cells = row.querySelectorAll("td");
|
|
50
|
+
|
|
51
|
+
// Apply responsive row layout
|
|
52
|
+
row.classList.add("block", "sm:table-row", "mb-4", "sm:mb-0", "transition", "duration-300", "ease-in-out", "hover:bg-gray-50");
|
|
53
|
+
|
|
54
|
+
// Apply responsive column layout
|
|
55
|
+
cells.forEach((cell, index) => {
|
|
56
|
+
cell.setAttribute("data-label", headers[index] || "");
|
|
57
|
+
cell.classList.add("block", "sm:table-cell", "text-left", "h-[3.2rem]", "px-2", "py-2");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<style css>
|
|
66
|
+
.table-custom {
|
|
67
|
+
@apply table-auto w-full border-collapse border-gray-300 !bg-transparent;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.table-custom th {
|
|
71
|
+
@apply px-4 py-2 text-left bg-gray-100 border-b border-gray-300 !bg-transparent;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.table-custom td {
|
|
75
|
+
@apply h-[3.2rem] px-2 py-2;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.table-custom tr {
|
|
79
|
+
@apply transition duration-300 ease-in-out hover:bg-gray-50;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.table-custom tr:not(:last-child) {
|
|
83
|
+
@apply border-b border-gray-300;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.table-custom td::before {
|
|
87
|
+
content: attr(data-label);
|
|
88
|
+
@apply font-semibold mr-2 block sm:hidden;
|
|
89
|
+
}
|
|
90
|
+
</style>
|
package/src/ui/kTabs.vue
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="w-full overflow-hidden bg-white border rounded-md">
|
|
3
|
+
<div class="flex">
|
|
4
|
+
<button
|
|
5
|
+
v-for="tab in tabs"
|
|
6
|
+
:key="tab.id"
|
|
7
|
+
@click="selectTab(tab.id)"
|
|
8
|
+
:class="['px-8 py-4 text-lg font-medium', modelValue === tab.id ? 'bg-primary text-secondary ' : 'text-gray-500 hover:text-gray-700']"
|
|
9
|
+
>
|
|
10
|
+
{{ tab.title }}
|
|
11
|
+
</button>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
15
|
+
|
|
16
|
+
<script>
|
|
17
|
+
export default {
|
|
18
|
+
name: "TabList",
|
|
19
|
+
props: {
|
|
20
|
+
modelValue: { type: [String, Number], required: true },
|
|
21
|
+
tabs: {
|
|
22
|
+
type: Array,
|
|
23
|
+
required: true,
|
|
24
|
+
validator: (value) => {
|
|
25
|
+
return value.every((tab) => "id" in tab && "title" in tab);
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
emits: ["update:modelValue"],
|
|
30
|
+
methods: {
|
|
31
|
+
selectTab(id) {
|
|
32
|
+
this.$emit("update:modelValue", id);
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
</script>
|
package/src/ui/kTags.vue
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="w-full">
|
|
3
|
+
<div v-if="label != null" class="inputLabel" :class="hasError ? 'text-red-500' : 'text-gray-700'">
|
|
4
|
+
{{ label }}
|
|
5
|
+
</div>
|
|
6
|
+
<div class="flex flex-wrap items-center gap-2 p-2 border border-gray-300 rounded-xl shadow-sm min-h-[3rem]">
|
|
7
|
+
<span v-for="(tag, index) in internalTags" :key="index" class="flex items-center px-2 py-1 text-blue-700 bg-blue-100 rounded-full">
|
|
8
|
+
{{ tag }}
|
|
9
|
+
<button @click="removeTag(index)" class="ml-1 text-blue-500 hover:text-blue-700">×</button>
|
|
10
|
+
</span>
|
|
11
|
+
<input
|
|
12
|
+
v-model="newTag"
|
|
13
|
+
@keydown.enter.prevent="addTag"
|
|
14
|
+
@keydown.backspace="handleBackspace"
|
|
15
|
+
:placeholder="placeholder"
|
|
16
|
+
class="flex-1 p-1 bg-transparent border-none focus:outline-none"
|
|
17
|
+
/>
|
|
18
|
+
</div>
|
|
19
|
+
<!-- Error message -->
|
|
20
|
+
<div class="text-sm text-red-500" v-if="hasError && error !== true && error !== ''">
|
|
21
|
+
{{ error }}
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<!-- Info message -->
|
|
25
|
+
<div class="text-sm text-gray-500" v-if="info != null">
|
|
26
|
+
{{ info }}
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</template>
|
|
30
|
+
|
|
31
|
+
<script>
|
|
32
|
+
export default {
|
|
33
|
+
name: "TagsInput",
|
|
34
|
+
props: {
|
|
35
|
+
modelValue: { type: Array, default: () => [] },
|
|
36
|
+
label: { type: String, default: null },
|
|
37
|
+
info: { type: String, default: null },
|
|
38
|
+
error: { type: [String, Boolean], default: null },
|
|
39
|
+
disabled: { type: Boolean, default: false },
|
|
40
|
+
icon: { type: String, default: null },
|
|
41
|
+
placeholder: { type: String, default: null },
|
|
42
|
+
id: String,
|
|
43
|
+
},
|
|
44
|
+
data() {
|
|
45
|
+
return {
|
|
46
|
+
newTag: "",
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
computed: {
|
|
50
|
+
hasError() {
|
|
51
|
+
return this.error != null && this.error !== false;
|
|
52
|
+
},
|
|
53
|
+
internalTags: {
|
|
54
|
+
get() {
|
|
55
|
+
return this.modelValue;
|
|
56
|
+
},
|
|
57
|
+
set(val) {
|
|
58
|
+
this.$emit("update:modelValue", val);
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
methods: {
|
|
63
|
+
addTag() {
|
|
64
|
+
const trimmed = this.newTag.trim();
|
|
65
|
+
if (trimmed && !this.internalTags.includes(trimmed)) {
|
|
66
|
+
this.internalTags = [...this.internalTags, trimmed];
|
|
67
|
+
}
|
|
68
|
+
this.newTag = "";
|
|
69
|
+
},
|
|
70
|
+
handleBackspace(e) {
|
|
71
|
+
if (this.newTag === "" && this.internalTags.length) {
|
|
72
|
+
this.removeTag(this.internalTags.length - 1);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
removeTag(index) {
|
|
76
|
+
const updated = [...this.internalTags];
|
|
77
|
+
updated.splice(index, 1);
|
|
78
|
+
this.internalTags = updated;
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<style scoped>
|
|
85
|
+
input::-webkit-input-placeholder {
|
|
86
|
+
color: #9ca3af; /* Tailwind gray-400 */
|
|
87
|
+
}
|
|
88
|
+
</style>
|