ketekny-ui-kit 1.0.17 → 1.0.18

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 +233 -229
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.18",
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,102 +1,101 @@
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"
21
+ aria-label="Clear selection"
22
+ @pointerdown.stop.prevent="clearSelection"
23
+ @click.stop.prevent="clearSelection"
24
+ >
25
+ <X class="w-5 h-5 mr-2" />
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-5 h-5" v-if="!open" />
30
+ <ChevronUp class="w-5 h-5" v-else />
31
+ </span>
65
32
  </div>
33
+ </SelectTrigger>
66
34
 
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>
35
+ <SelectPortal>
36
+ <SelectContent
37
+ :id="dropdownId"
38
+ position="popper"
39
+ :side-offset="6"
40
+ class="z-[9999] overflow-hidden 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"
41
+ >
42
+ <div v-if="searchable" data-select-search-header class="sticky top-0 z-10 pt-2 pb-2 mx-2 bg-white">
43
+ <input
44
+ ref="searchInput"
45
+ type="text"
46
+ v-model="searchQuery"
47
+ placeholder="Αναζήτηση..."
48
+ class="w-full px-3 py-2 border border-gray-200 rounded-md focus:outline-none"
49
+ role="searchbox"
50
+ aria-label="Search options"
51
+ @keydown.stop="handleSearchKeydown"
52
+ />
87
53
  </div>
88
- </div>
89
54
 
90
- <div v-if="filteredOptions.length === 0" class="px-3 py-2 text-gray-400">Δεν βρέθηκαν αποτελέσματα</div>
91
- </div>
92
- </Teleport>
55
+ <div class="relative">
56
+ <SelectViewport
57
+ class="p-2 overflow-y-auto"
58
+ :class="canScrollAny ? 'pt-8 pb-8' : ''"
59
+ :style="{ maxHeight: viewportMaxHeight }"
60
+ @scroll="updateScrollState"
61
+ >
62
+ <div class="flex flex-col gap-0">
63
+ <SelectItem
64
+ v-for="(option, index) in filteredOptions"
65
+ :key="`${option[optionValue]}-${index}`"
66
+ :value="option[optionValue]"
67
+ class="px-3 py-2 rounded cursor-pointer outline-none text-slate-700 hover:bg-primary/20 hover:text-primary data-[highlighted]:bg-primary data-[highlighted]:text-white data-[state=checked]:text-primary data-[state=checked]:data-[highlighted]:text-white"
68
+ >
69
+ <div class="flex items-center justify-between gap-2">
70
+ <SelectItemText class="truncate">{{ option[optionLabel] }}</SelectItemText>
71
+ <span class="inline-flex items-center justify-center w-4 h-4 shrink-0">
72
+ <SelectItemIndicator>
73
+ <Check class="w-4 h-4" />
74
+ </SelectItemIndicator>
75
+ </span>
76
+ </div>
77
+ </SelectItem>
78
+ </div>
79
+
80
+ <div v-if="filteredOptions.length === 0" class="px-3 py-2 text-gray-400">Δεν βρέθηκαν επιλογές</div>
81
+ </SelectViewport>
82
+
83
+ <SelectScrollUpButton class="absolute top-0 left-0 right-0 z-10 flex items-center justify-center h-6 text-gray-500 bg-white/95">
84
+ <ChevronUp class="w-4 h-4" />
85
+ </SelectScrollUpButton>
86
+
87
+ <SelectScrollDownButton class="absolute bottom-0 left-0 right-0 z-10 flex items-center justify-center h-6 text-gray-500 bg-white/95">
88
+ <ChevronDown class="w-4 h-4" />
89
+ </SelectScrollDownButton>
90
+ </div>
91
+ </SelectContent>
92
+ </SelectPortal>
93
+ </SelectRoot>
93
94
 
94
- <!-- Error -->
95
95
  <div class="mt-1 text-red-500" v-if="hasError && error !== true && error !== ''">
96
96
  {{ error }}
97
97
  </div>
98
98
 
99
- <!-- Info -->
100
99
  <div class="mt-1 text-gray-500" v-if="info">
101
100
  {{ info }}
102
101
  </div>
@@ -104,163 +103,168 @@
104
103
  </template>
105
104
 
106
105
  <script setup>
106
+ import { computed, nextTick, ref, watch } from "vue";
107
+ import {
108
+ SelectContent,
109
+ SelectItem,
110
+ SelectItemIndicator,
111
+ SelectItemText,
112
+ SelectPortal,
113
+ SelectRoot,
114
+ SelectScrollDownButton,
115
+ SelectScrollUpButton,
116
+ SelectTrigger,
117
+ SelectValue,
118
+ SelectViewport,
119
+ } from "reka-ui";
107
120
  import { X, ChevronDown, ChevronUp, Check } from "lucide-vue-next";
108
- </script>
109
121
 
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
- };
122
+ const props = defineProps({
123
+ options: { type: Array, required: true },
124
+ modelValue: [String, Number],
125
+ optionValue: { type: String, default: "value" },
126
+ optionLabel: { type: String, default: "label" },
127
+ label: String,
128
+ info: String,
129
+ error: [String, Boolean],
130
+ disabled: { type: Boolean, default: false },
131
+ placeholder: { type: String, default: "Επιλέξτε μία τιμή" },
132
+ id: { type: String, default: null },
133
+ clearable: { type: Boolean, default: true },
134
+ searchable: { type: Boolean, default: true },
135
+ });
136
+
137
+ const emit = defineEmits(["update:modelValue"]);
138
+
139
+ const open = ref(false);
140
+ const searchQuery = ref("");
141
+ const searchInput = ref(null);
142
+ const canScrollAny = ref(false);
143
+ const generatedId = `select-${Math.random().toString(36).substr(2, 9)}`;
144
+ const viewportMaxHeight = "400px";
145
+ 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";
146
+ const errorStyle = "border-red-500 focus:ring focus:ring-red-300";
147
+ const disabledStyle = "!bg-gray-100 !text-gray-400 !cursor-not-allowed";
148
+
149
+ const computedId = computed(() => (props.id != null && props.id !== "" ? props.id : generatedId));
150
+ const dropdownId = computed(() => `${computedId.value}-listbox`);
151
+
152
+ const hasError = computed(() => props.error != null && props.error !== false);
153
+ const isSelectionSet = computed(() => props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== "");
154
+
155
+ const internalValue = computed({
156
+ get() {
157
+ return props.modelValue === null ? undefined : props.modelValue;
137
158
  },
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
- },
159
+ set(val) {
160
+ emit("update:modelValue", val ?? null);
162
161
  },
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));
162
+ });
203
163
 
204
- if (clickedOutside) {
205
- this.closeDropdown();
206
- }
207
- },
208
- handleViewportChange() {
209
- if (!this.isOpen) return;
210
- this.getDropdownPosition();
211
- },
164
+ const filteredOptions = computed(() => {
165
+ if (!props.searchable) return props.options;
166
+ const q = searchQuery.value.trim().toLowerCase();
167
+ if (!q) return props.options;
168
+ return props.options.filter((o) => String(o?.[props.optionLabel] ?? "").toLowerCase().includes(q));
169
+ });
212
170
 
213
- getDropdownPosition() {
214
- const trigger = this.$refs.trigger;
215
- if (!trigger) return;
216
- const dropdown = this.$refs.dropdown;
171
+ watch(open, (val) => {
172
+ if (val) {
173
+ nextTick(() => {
174
+ if (props.searchable) searchInput.value?.focus();
175
+ updateScrollState();
176
+ });
177
+ return;
178
+ }
179
+ searchQuery.value = "";
180
+ canScrollAny.value = false;
181
+ });
217
182
 
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;
183
+ watch(filteredOptions, () => {
184
+ if (!open.value) return;
185
+ nextTick(() => updateScrollState());
186
+ });
224
187
 
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);
188
+ function clearSelection() {
189
+ emit("update:modelValue", null);
190
+ open.value = false;
191
+ }
230
192
 
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);
193
+ function focusFirstOrLastOption(last = false) {
194
+ const container = document.getElementById(dropdownId.value);
195
+ if (!container) return;
196
+ const nodes = Array.from(container.querySelectorAll('[role="option"]:not([data-disabled])'));
197
+ if (!nodes.length) return;
198
+ const target = last ? nodes[nodes.length - 1] : nodes[0];
199
+ target.focus();
200
+ }
234
201
 
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);
202
+ function moveOptionFocus(step) {
203
+ const container = document.getElementById(dropdownId.value);
204
+ if (!container) return;
205
+ const nodes = Array.from(container.querySelectorAll('[role="option"]:not([data-disabled])'));
206
+ if (!nodes.length) return;
207
+ const activeIndex = nodes.findIndex((node) => node === document.activeElement);
208
+ const baseIndex = activeIndex >= 0 ? activeIndex : step > 0 ? -1 : 0;
209
+ const nextIndex = Math.min(Math.max(baseIndex + step, 0), nodes.length - 1);
210
+ nodes[nextIndex].focus();
211
+ }
239
212
 
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));
213
+ function handleSearchKeydown(event) {
214
+ if (event.key === "Escape") {
215
+ event.preventDefault();
216
+ event.stopPropagation();
217
+ open.value = false;
218
+ return;
219
+ }
244
220
 
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
- };
221
+ if (event.key === "ArrowDown") {
222
+ event.preventDefault();
223
+ event.stopPropagation();
224
+ moveOptionFocus(1);
225
+ return;
226
+ }
227
+
228
+ if (event.key === "ArrowUp") {
229
+ event.preventDefault();
230
+ event.stopPropagation();
231
+ moveOptionFocus(-1);
232
+ return;
233
+ }
234
+
235
+ if (event.key === "Home") {
236
+ event.preventDefault();
237
+ event.stopPropagation();
238
+ focusFirstOrLastOption(false);
239
+ return;
240
+ }
241
+
242
+ if (event.key === "End") {
243
+ event.preventDefault();
244
+ event.stopPropagation();
245
+ focusFirstOrLastOption(true);
246
+ return;
247
+ }
248
+
249
+ if (event.key === "Enter") {
250
+ const container = document.getElementById(dropdownId.value);
251
+ if (!container) return;
252
+ const active = document.activeElement;
253
+ if (active && container.contains(active) && active.getAttribute("role") === "option") {
254
+ event.preventDefault();
255
+ event.stopPropagation();
256
+ active.click();
257
+ }
258
+ }
259
+ }
260
+
261
+ function updateScrollState() {
262
+ const container = document.getElementById(dropdownId.value);
263
+ const viewport = container?.querySelector("[data-reka-select-viewport]");
264
+ if (!viewport) {
265
+ canScrollAny.value = false;
266
+ return;
267
+ }
268
+ canScrollAny.value = viewport.scrollHeight > viewport.clientHeight + 1;
269
+ }
266
270
  </script>