ketekny-ui-kit 1.0.17 → 1.0.19

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.
Files changed (2) hide show
  1. package/package.json +3 -2
  2. package/src/ui/kSelect.vue +230 -232
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ketekny-ui-kit",
3
3
  "type": "module",
4
- "version": "1.0.17",
4
+ "version": "1.0.19",
5
5
  "description": "A Vue 3 UI component library with Tailwind CSS styling",
6
6
  "main": "index.js",
7
7
  "files": [
@@ -40,13 +40,14 @@
40
40
  "dependencies": {
41
41
  "@primeuix/themes": "^1.1.1",
42
42
  "@vuepic/vue-datepicker": "^11.0.2",
43
- "json-editor-vue": "^0.18.1",
44
43
  "he-tree-vue": "^3.1.2",
44
+ "json-editor-vue": "^0.18.1",
45
45
  "lucide-vue-next": "^0.511.0",
46
46
  "moment": "^2.30.1",
47
47
  "primeicons": "^7.0.0",
48
48
  "primevue": "^4.3.4",
49
49
  "quill": "^2.0.3",
50
+ "reka-ui": "^2.8.2",
50
51
  "simple-code-editor": "^2.0.9",
51
52
  "vue-router": "^4.6.4",
52
53
  "vue3-easy-data-table": "^1.5.47"
@@ -1,266 +1,264 @@
1
1
  <template>
2
2
  <div class="relative w-full">
3
- <!-- Label -->
4
3
  <label v-if="label != null" :for="computedId" class="inputLabel" :class="hasError ? 'text-red-500' : 'text-gray-700'">
5
4
  {{ label }}
6
5
  </label>
7
6
 
8
- <!-- Input Trigger -->
9
- <div
10
- ref="trigger"
11
- @click="toggleDropdown"
12
- @keydown="handleTriggerKeydown"
13
- class="relative w-full px-3 py-2 text-gray-700 bg-white border rounded-lg cursor-pointer trigger-box"
14
- :class="[defaultStyle, hasError ? errorStyle : '', disabled ? disabledStyle : '']"
15
- :aria-haspopup="'listbox'"
16
- :aria-expanded="isOpen.toString()"
17
- :aria-disabled="disabled.toString()"
18
- :aria-controls="dropdownId"
19
- :role="'combobox'"
20
- :tabindex="disabled ? -1 : 0"
21
- >
22
- <span class="block pr-10 truncate">{{ selectedLabel || placeholder }}</span>
7
+ <SelectRoot v-model="internalValue" v-model:open="open" :disabled="disabled">
8
+ <SelectTrigger as-child>
9
+ <div
10
+ :id="computedId"
11
+ class="relative w-full px-3 py-2 text-gray-700 bg-white border rounded-lg cursor-pointer trigger-box"
12
+ :class="[defaultStyle, hasError ? errorStyle : '', disabled ? disabledStyle : '']"
13
+ :aria-label="label || 'Select option'"
14
+ >
15
+ <SelectValue :placeholder="placeholder" class="block pr-10 truncate" />
23
16
 
24
- <!-- Clear button -->
25
- <button
26
- v-if="clearable && isSelectionSet && !disabled"
27
- type="button"
28
- class="absolute text-gray-400 -translate-y-1/2 right-8 top-1/2 hover:text-red-500"
29
- @click.stop="clearSelection"
30
- aria-label="Clear selection">
31
- <X class="w-5 h-5 mr-2" />
32
- </button>
33
-
34
- <!-- Dropdown icon -->
35
- <span class="absolute text-gray-400 -translate-y-1/2 pointer-events-none right-3 top-1/2">
36
- <ChevronDown class="w-5 h-5" v-if="!isOpen" />
37
- <ChevronUp class="w-5 h-5" v-else />
38
- </span>
39
- </div>
17
+ <button
18
+ v-if="clearable && isSelectionSet && !disabled"
19
+ type="button"
20
+ class="absolute text-gray-400 -translate-y-1/2 right-8 top-1/2 hover:text-red-500 transition-colors"
21
+ aria-label="Clear selection"
22
+ @pointerdown.stop.prevent="clearSelection"
23
+ @click.stop.prevent="clearSelection"
24
+ >
25
+ <X class="w-4 h-4" />
26
+ </button>
40
27
 
41
- <!-- Dropdown Menu -->
42
- <Teleport to="body">
43
- <div
44
- v-if="isOpen"
45
- ref="dropdown"
46
- :id="dropdownId"
47
- class="absolute z-[9999] mt-1 overflow-auto bg-white border border-gray-200 rounded-lg shadow-[0_20px_45px_-15px_rgba(15,23,42,0.35),0_8px_18px_-10px_rgba(15,23,42,0.25)] ring-1 ring-slate-900/10"
48
- :class="dropdownHeight"
49
- :style="dropdownPositionStyle"
50
- role="listbox"
51
- :aria-label="label || 'Select options'"
52
- >
53
- <!-- Search -->
54
- <div class="sticky top-0 z-10 pt-2 pb-2 mx-2 bg-white">
55
- <input
56
- ref="searchInput"
57
- type="text"
58
- v-model="searchQuery"
59
- placeholder="Αναζήτηση..."
60
- class="w-full px-3 py-2 border border-gray-200 rounded-md focus:outline-none"
61
- role="searchbox"
62
- aria-label="Search options"
63
- @keydown.esc.stop.prevent="closeDropdown"
64
- />
28
+ <span class="absolute text-gray-400 -translate-y-1/2 pointer-events-none right-3 top-1/2">
29
+ <ChevronDown class="w-4 h-4 transition-transform duration-200" :class="{ 'rotate-180': open }" />
30
+ </span>
65
31
  </div>
32
+ </SelectTrigger>
66
33
 
67
- <!-- Filtered Options -->
68
- <div class="flex flex-col gap-[2px] p-2">
69
- <div
70
- v-for="option in filteredOptions"
71
- :id="`${dropdownId}-opt-${option[optionValue]}`"
72
- :key="option[optionValue]"
73
- @click="selectOption(option)"
74
- class="px-3 py-2 rounded cursor-pointer transition-colors"
75
- :class="
76
- option[optionValue] === selectedValue
77
- ? 'bg-primary/10 text-primary font-medium'
78
- : 'text-slate-700 hover:bg-primary/5'
79
- "
80
- role="option"
81
- :aria-selected="(option[optionValue] === selectedValue).toString()"
82
- >
83
- <div class="flex items-center justify-between gap-2">
84
- <span class="truncate">{{ option[optionLabel] }}</span>
85
- <Check v-if="option[optionValue] === selectedValue" class="w-4 h-4 shrink-0 text-primary" />
86
- </div>
34
+ <SelectPortal>
35
+ <SelectContent
36
+ position="popper"
37
+ :side-offset="6"
38
+ class="select-content z-[9999] bg-white border border-gray-200 rounded-lg shadow-[0_10px_38px_-10px_rgba(22,23,24,0.35),0_10px_20px_-15px_rgba(22,23,24,0.2)] overflow-hidden"
39
+ >
40
+ <div v-if="searchable" class="sticky top-0 z-10 p-1.5 border-b border-gray-100 bg-white">
41
+ <input
42
+ ref="searchInput"
43
+ type="text"
44
+ v-model="searchQuery"
45
+ placeholder="Αναζήτηση..."
46
+ class="w-full px-2.5 py-1.5 text-sm border border-gray-200 rounded-md focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30 transition-colors placeholder-gray-400"
47
+ role="searchbox"
48
+ aria-label="Search options"
49
+ @keydown="handleSearchKeydown"
50
+ @keyup.stop
51
+ />
87
52
  </div>
88
- </div>
89
53
 
90
- <div v-if="filteredOptions.length === 0" class="px-3 py-2 text-gray-400">Δεν βρέθηκαν αποτελέσματα</div>
91
- </div>
92
- </Teleport>
54
+ <SelectViewport
55
+ ref="viewportRef"
56
+ class="p-1 overflow-y-auto"
57
+ :style="{ maxHeight: dropdownHeight }"
58
+ >
59
+ <SelectItem
60
+ v-for="(option, index) in filteredOptions"
61
+ :key="`${option[optionValue]}-${index}`"
62
+ :value="option[optionValue]"
63
+ class="select-item relative flex items-center gap-1.5 rounded-[4px] py-1.5 pl-6 pr-3 text-sm cursor-pointer outline-none select-none text-slate-700
64
+ data-[highlighted]:bg-primary data-[highlighted]:text-white
65
+ data-[state=checked]:text-primary
66
+ data-[state=checked]:data-[highlighted]:text-white"
67
+ >
68
+ <span class="absolute left-1.5 flex items-center justify-center w-4 h-4 shrink-0">
69
+ <SelectItemIndicator>
70
+ <Check class="w-3.5 h-3.5" />
71
+ </SelectItemIndicator>
72
+ </span>
73
+ <SelectItemText class="truncate">{{ option[optionLabel] }}</SelectItemText>
74
+ </SelectItem>
93
75
 
94
- <!-- Error -->
95
- <div class="mt-1 text-red-500" v-if="hasError && error !== true && error !== ''">
76
+ <div v-if="filteredOptions.length === 0" class="px-3 py-2 text-sm text-gray-400 text-center">
77
+ Δεν βρέθηκαν επιλογές
78
+ </div>
79
+ </SelectViewport>
80
+ </SelectContent>
81
+ </SelectPortal>
82
+ </SelectRoot>
83
+
84
+ <div class="mt-1 text-sm text-red-500" v-if="hasError && error !== true && error !== ''">
96
85
  {{ error }}
97
86
  </div>
98
87
 
99
- <!-- Info -->
100
- <div class="mt-1 text-gray-500" v-if="info">
88
+ <div class="mt-1 text-sm text-gray-500" v-if="info">
101
89
  {{ info }}
102
90
  </div>
103
91
  </div>
104
92
  </template>
105
93
 
106
94
  <script setup>
107
- import { X, ChevronDown, ChevronUp, Check } from "lucide-vue-next";
108
- </script>
95
+ import { computed, nextTick, ref, watch } from "vue";
96
+ import {
97
+ SelectContent,
98
+ SelectItem,
99
+ SelectItemIndicator,
100
+ SelectItemText,
101
+ SelectPortal,
102
+ SelectRoot,
103
+ SelectTrigger,
104
+ SelectValue,
105
+ SelectViewport,
106
+ } from "reka-ui";
107
+ import { X, ChevronDown, Check } from "lucide-vue-next";
109
108
 
110
- <script>
111
- export default {
112
- name: "kSelect",
113
- props: {
114
- options: { type: Array, required: true },
115
- modelValue: [String, Number],
116
- optionValue: { type: String, default: "value" },
117
- optionLabel: { type: String, default: "label" },
118
- label: String,
119
- info: String,
120
- error: [String, Boolean],
121
- disabled: { type: Boolean, default: false },
122
- placeholder: { type: String, default: "Επιλέξτε μία τιμή" },
123
- id: { type: String, default: null },
124
- dropdownHeight: { type: String, default: "max-h-80" },
125
- clearable: { type: Boolean, default: true },
126
- },
127
- data() {
128
- return {
129
- isOpen: false,
130
- searchQuery: "",
131
- dropdownPositionStyle: {},
132
- generatedId: `select-${Math.random().toString(36).substr(2, 9)}`,
133
- defaultStyle: "w-full px-3 py-2 border rounded-lg transition shadow-sm focus:outline-none text-gray-700 focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white placeholder-gray-400",
134
- errorStyle: "border-red-500 focus:ring focus:ring-red-300",
135
- disabledStyle: "!bg-gray-100 !text-gray-400 !cursor-not-allowed",
136
- };
109
+ const props = defineProps({
110
+ options: { type: Array, required: true },
111
+ modelValue: [String, Number],
112
+ optionValue: { type: String, default: "value" },
113
+ optionLabel: { type: String, default: "label" },
114
+ label: String,
115
+ info: String,
116
+ error: [String, Boolean],
117
+ disabled: { type: Boolean, default: false },
118
+ placeholder: { type: String, default: "Επιλέξτε μία τιμή" },
119
+ id: { type: String, default: null },
120
+ dropdownHeight: { type: String, default: "min(400px, 40vh)" },
121
+ clearable: { type: Boolean, default: true },
122
+ searchable: { type: Boolean, default: true },
123
+ });
124
+
125
+ const emit = defineEmits(["update:modelValue"]);
126
+
127
+ const open = ref(false);
128
+ const searchQuery = ref("");
129
+ const searchInput = ref(null);
130
+ const viewportRef = ref(null);
131
+ const generatedId = `select-${Math.random().toString(36).substr(2, 9)}`;
132
+ const defaultStyle = "w-full px-3 py-2 border rounded-lg transition shadow-sm focus:outline-none text-gray-700 focus:ring-2 focus:ring-primary/20 focus:border-primary bg-white placeholder-gray-400";
133
+ const errorStyle = "border-red-500 focus:ring focus:ring-red-300";
134
+ const disabledStyle = "!bg-gray-100 !text-gray-400 !cursor-not-allowed";
135
+
136
+ const computedId = computed(() => (props.id != null && props.id !== "" ? props.id : generatedId));
137
+ const hasError = computed(() => props.error != null && props.error !== false);
138
+ const isSelectionSet = computed(() => props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== "");
139
+
140
+ const internalValue = computed({
141
+ get() {
142
+ return props.modelValue === null ? undefined : props.modelValue;
137
143
  },
138
- computed: {
139
- computedId() {
140
- return this.id != null && this.id !== "" ? this.id : this.generatedId;
141
- },
142
- dropdownId() {
143
- return `${this.computedId}-listbox`;
144
- },
145
- selectedValue() {
146
- return this.modelValue;
147
- },
148
- isSelectionSet() {
149
- return this.selectedValue !== null && this.selectedValue !== undefined && this.selectedValue !== "";
150
- },
151
- selectedLabel() {
152
- const match = this.options.find((o) => o[this.optionValue] === this.modelValue);
153
- return match ? match[this.optionLabel] : "";
154
- },
155
- filteredOptions() {
156
- const q = this.searchQuery.trim().toLowerCase();
157
- return this.options.filter((o) => String(o[this.optionLabel] ?? "").toLowerCase().includes(q));
158
- },
159
- hasError() {
160
- return this.error != null && this.error !== false;
161
- },
144
+ set(val) {
145
+ emit("update:modelValue", val ?? null);
162
146
  },
163
- methods: {
164
- toggleDropdown() {
165
- if (this.disabled) return;
166
- this.isOpen = !this.isOpen;
167
- if (this.isOpen) {
168
- this.$nextTick(() => {
169
- this.getDropdownPosition();
170
- this.$refs.searchInput?.focus();
171
- });
172
- } else {
173
- this.searchQuery = "";
174
- }
175
- },
176
- closeDropdown() {
177
- this.isOpen = false;
178
- this.searchQuery = "";
179
- },
180
- selectOption(option) {
181
- this.$emit("update:modelValue", option[this.optionValue]);
182
- this.closeDropdown();
183
- },
184
- clearSelection() {
185
- this.$emit("update:modelValue", null);
186
- this.closeDropdown();
187
- },
188
- handleTriggerKeydown(event) {
189
- if (this.disabled) return;
190
- if (event.key === "Enter" || event.key === " ") {
191
- event.preventDefault();
192
- this.toggleDropdown();
193
- } else if (event.key === "ArrowDown" && !this.isOpen) {
194
- event.preventDefault();
195
- this.toggleDropdown();
196
- } else if (event.key === "Escape" && this.isOpen) {
197
- event.preventDefault();
198
- this.closeDropdown();
199
- }
200
- },
201
- closeOnClickOutside(e) {
202
- const clickedOutside = !this.$el.contains(e.target) && !(this.$refs.dropdown && this.$refs.dropdown.contains(e.target));
147
+ });
203
148
 
204
- if (clickedOutside) {
205
- this.closeDropdown();
206
- }
207
- },
208
- handleViewportChange() {
209
- if (!this.isOpen) return;
210
- this.getDropdownPosition();
211
- },
149
+ const filteredOptions = computed(() => {
150
+ if (!props.searchable) return props.options;
151
+ const q = searchQuery.value.trim().toLowerCase();
152
+ if (!q) return props.options;
153
+ return props.options.filter((o) => String(o?.[props.optionLabel] ?? "").toLowerCase().includes(q));
154
+ });
212
155
 
213
- getDropdownPosition() {
214
- const trigger = this.$refs.trigger;
215
- if (!trigger) return;
216
- const dropdown = this.$refs.dropdown;
156
+ watch(open, (val) => {
157
+ if (val) {
158
+ nextTick(() => {
159
+ if (props.searchable) searchInput.value?.focus();
160
+ });
161
+ return;
162
+ }
163
+ searchQuery.value = "";
164
+ });
217
165
 
218
- const rect = trigger.getBoundingClientRect();
219
- const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
220
- const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
221
- const viewportHeight = window.innerHeight;
222
- const viewportWidth = window.innerWidth;
223
- const pad = 8;
166
+ function clearSelection() {
167
+ emit("update:modelValue", null);
168
+ open.value = false;
169
+ }
224
170
 
225
- const spaceBelow = viewportHeight - rect.bottom;
226
- const spaceAbove = rect.top;
227
- const contentHeight = dropdown ? dropdown.scrollHeight : 320;
228
- const maxViewportHeight = Math.max(120, viewportHeight - pad * 2);
229
- const desiredHeight = Math.min(contentHeight, maxViewportHeight);
171
+ function getOptionNodes() {
172
+ const el = viewportRef.value?.$el ?? viewportRef.value;
173
+ if (!el) return [];
174
+ return Array.from(el.querySelectorAll('[role="option"]:not([data-disabled])'));
175
+ }
230
176
 
231
- const shouldOpenAbove = spaceBelow < Math.min(240, desiredHeight) && spaceAbove > spaceBelow;
232
- const availableHeight = Math.max(120, (shouldOpenAbove ? spaceAbove : spaceBelow) - pad);
233
- const finalHeight = Math.min(desiredHeight, availableHeight);
177
+ function handleSearchKeydown(event) {
178
+ // Always stop propagation to prevent reka-ui typeahead on character keys
179
+ event.stopPropagation();
234
180
 
235
- const rawTop = shouldOpenAbove ? rect.top + scrollTop - finalHeight : rect.bottom + scrollTop;
236
- const minTop = scrollTop + pad;
237
- const maxTop = scrollTop + viewportHeight - finalHeight - pad;
238
- const top = Math.min(Math.max(rawTop, minTop), maxTop);
181
+ if (event.key === "Escape") {
182
+ event.preventDefault();
183
+ open.value = false;
184
+ return;
185
+ }
239
186
 
240
- const rawLeft = rect.left + scrollLeft;
241
- const minLeft = scrollLeft + pad;
242
- const maxLeft = scrollLeft + viewportWidth - rect.width - pad;
243
- const left = Math.min(Math.max(rawLeft, minLeft), Math.max(minLeft, maxLeft));
187
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
188
+ event.preventDefault();
189
+ const nodes = getOptionNodes();
190
+ if (!nodes.length) return;
191
+ const activeIndex = nodes.findIndex((n) => n === document.activeElement);
192
+ if (event.key === "ArrowDown") {
193
+ const next = activeIndex < nodes.length - 1 ? nodes[activeIndex + 1] : nodes[0];
194
+ next.focus();
195
+ } else {
196
+ const prev = activeIndex > 0 ? nodes[activeIndex - 1] : nodes[nodes.length - 1];
197
+ prev.focus();
198
+ }
199
+ return;
200
+ }
244
201
 
245
- this.dropdownPositionStyle = {
246
- position: "absolute",
247
- top: `${top}px`,
248
- left: `${left}px`,
249
- minWidth: `${rect.width}px`,
250
- maxWidth: `500px`,
251
- maxHeight: `${finalHeight}px`,
252
- };
253
- },
254
- },
255
- mounted() {
256
- document.addEventListener("click", this.closeOnClickOutside);
257
- window.addEventListener("resize", this.handleViewportChange);
258
- window.addEventListener("scroll", this.handleViewportChange, true);
259
- },
260
- beforeUnmount() {
261
- document.removeEventListener("click", this.closeOnClickOutside);
262
- window.removeEventListener("resize", this.handleViewportChange);
263
- window.removeEventListener("scroll", this.handleViewportChange, true);
264
- },
265
- };
202
+ if (event.key === "Home") {
203
+ event.preventDefault();
204
+ const nodes = getOptionNodes();
205
+ nodes[0]?.focus();
206
+ return;
207
+ }
208
+
209
+ if (event.key === "End") {
210
+ event.preventDefault();
211
+ const nodes = getOptionNodes();
212
+ nodes[nodes.length - 1]?.focus();
213
+ return;
214
+ }
215
+
216
+ if (event.key === "Enter") {
217
+ event.preventDefault();
218
+ const nodes = getOptionNodes();
219
+ const focused = nodes.find((n) => n === document.activeElement);
220
+ if (focused) focused.click();
221
+ }
222
+ }
266
223
  </script>
224
+
225
+ <style scoped>
226
+ .select-content {
227
+ transform-origin: var(--reka-select-content-transform-origin);
228
+ width: var(--reka-popper-anchor-width);
229
+ }
230
+
231
+ .select-content[data-state="open"] {
232
+ animation: selectOpen 0.15s cubic-bezier(0.16, 1, 0.3, 1);
233
+ }
234
+
235
+ .select-content[data-state="closed"] {
236
+ animation: selectClose 0.1s ease-in;
237
+ }
238
+
239
+ @keyframes selectOpen {
240
+ from {
241
+ opacity: 0;
242
+ transform: translateY(-6px) scale(0.97);
243
+ }
244
+ to {
245
+ opacity: 1;
246
+ transform: translateY(0) scale(1);
247
+ }
248
+ }
249
+
250
+ @keyframes selectClose {
251
+ from {
252
+ opacity: 1;
253
+ transform: translateY(0) scale(1);
254
+ }
255
+ to {
256
+ opacity: 0;
257
+ transform: translateY(-6px) scale(0.97);
258
+ }
259
+ }
260
+
261
+ .select-item {
262
+ transition: background-color 80ms, color 80ms;
263
+ }
264
+ </style>