ketekny-ui-kit 1.0.18 → 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 +1 -1
  2. package/src/ui/kSelect.vue +102 -108
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ketekny-ui-kit",
3
3
  "type": "module",
4
- "version": "1.0.18",
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": [
@@ -17,86 +17,75 @@
17
17
  <button
18
18
  v-if="clearable && isSelectionSet && !disabled"
19
19
  type="button"
20
- class="absolute text-gray-400 -translate-y-1/2 right-8 top-1/2 hover:text-red-500"
20
+ class="absolute text-gray-400 -translate-y-1/2 right-8 top-1/2 hover:text-red-500 transition-colors"
21
21
  aria-label="Clear selection"
22
22
  @pointerdown.stop.prevent="clearSelection"
23
23
  @click.stop.prevent="clearSelection"
24
24
  >
25
- <X class="w-5 h-5 mr-2" />
25
+ <X class="w-4 h-4" />
26
26
  </button>
27
27
 
28
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 />
29
+ <ChevronDown class="w-4 h-4 transition-transform duration-200" :class="{ 'rotate-180': open }" />
31
30
  </span>
32
31
  </div>
33
32
  </SelectTrigger>
34
33
 
35
34
  <SelectPortal>
36
35
  <SelectContent
37
- :id="dropdownId"
38
36
  position="popper"
39
37
  :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"
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"
41
39
  >
42
- <div v-if="searchable" data-select-search-header class="sticky top-0 z-10 pt-2 pb-2 mx-2 bg-white">
40
+ <div v-if="searchable" class="sticky top-0 z-10 p-1.5 border-b border-gray-100 bg-white">
43
41
  <input
44
42
  ref="searchInput"
45
43
  type="text"
46
44
  v-model="searchQuery"
47
45
  placeholder="Αναζήτηση..."
48
- class="w-full px-3 py-2 border border-gray-200 rounded-md focus:outline-none"
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"
49
47
  role="searchbox"
50
48
  aria-label="Search options"
51
- @keydown.stop="handleSearchKeydown"
49
+ @keydown="handleSearchKeydown"
50
+ @keyup.stop
52
51
  />
53
52
  </div>
54
53
 
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"
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"
61
67
  >
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>
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>
86
75
 
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>
76
+ <div v-if="filteredOptions.length === 0" class="px-3 py-2 text-sm text-gray-400 text-center">
77
+ Δεν βρέθηκαν επιλογές
78
+ </div>
79
+ </SelectViewport>
91
80
  </SelectContent>
92
81
  </SelectPortal>
93
82
  </SelectRoot>
94
83
 
95
- <div class="mt-1 text-red-500" v-if="hasError && error !== true && error !== ''">
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
- <div class="mt-1 text-gray-500" v-if="info">
88
+ <div class="mt-1 text-sm text-gray-500" v-if="info">
100
89
  {{ info }}
101
90
  </div>
102
91
  </div>
@@ -111,13 +100,11 @@ import {
111
100
  SelectItemText,
112
101
  SelectPortal,
113
102
  SelectRoot,
114
- SelectScrollDownButton,
115
- SelectScrollUpButton,
116
103
  SelectTrigger,
117
104
  SelectValue,
118
105
  SelectViewport,
119
106
  } from "reka-ui";
120
- import { X, ChevronDown, ChevronUp, Check } from "lucide-vue-next";
107
+ import { X, ChevronDown, Check } from "lucide-vue-next";
121
108
 
122
109
  const props = defineProps({
123
110
  options: { type: Array, required: true },
@@ -130,6 +117,7 @@ const props = defineProps({
130
117
  disabled: { type: Boolean, default: false },
131
118
  placeholder: { type: String, default: "Επιλέξτε μία τιμή" },
132
119
  id: { type: String, default: null },
120
+ dropdownHeight: { type: String, default: "min(400px, 40vh)" },
133
121
  clearable: { type: Boolean, default: true },
134
122
  searchable: { type: Boolean, default: true },
135
123
  });
@@ -139,16 +127,13 @@ const emit = defineEmits(["update:modelValue"]);
139
127
  const open = ref(false);
140
128
  const searchQuery = ref("");
141
129
  const searchInput = ref(null);
142
- const canScrollAny = ref(false);
130
+ const viewportRef = ref(null);
143
131
  const generatedId = `select-${Math.random().toString(36).substr(2, 9)}`;
144
- const viewportMaxHeight = "400px";
145
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";
146
133
  const errorStyle = "border-red-500 focus:ring focus:ring-red-300";
147
134
  const disabledStyle = "!bg-gray-100 !text-gray-400 !cursor-not-allowed";
148
135
 
149
136
  const computedId = computed(() => (props.id != null && props.id !== "" ? props.id : generatedId));
150
- const dropdownId = computed(() => `${computedId.value}-listbox`);
151
-
152
137
  const hasError = computed(() => props.error != null && props.error !== false);
153
138
  const isSelectionSet = computed(() => props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== "");
154
139
 
@@ -172,17 +157,10 @@ watch(open, (val) => {
172
157
  if (val) {
173
158
  nextTick(() => {
174
159
  if (props.searchable) searchInput.value?.focus();
175
- updateScrollState();
176
160
  });
177
161
  return;
178
162
  }
179
163
  searchQuery.value = "";
180
- canScrollAny.value = false;
181
- });
182
-
183
- watch(filteredOptions, () => {
184
- if (!open.value) return;
185
- nextTick(() => updateScrollState());
186
164
  });
187
165
 
188
166
  function clearSelection() {
@@ -190,81 +168,97 @@ function clearSelection() {
190
168
  open.value = false;
191
169
  }
192
170
 
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
- }
201
-
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();
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])'));
211
175
  }
212
176
 
213
177
  function handleSearchKeydown(event) {
178
+ // Always stop propagation to prevent reka-ui typeahead on character keys
179
+ event.stopPropagation();
180
+
214
181
  if (event.key === "Escape") {
215
182
  event.preventDefault();
216
- event.stopPropagation();
217
183
  open.value = false;
218
184
  return;
219
185
  }
220
186
 
221
- if (event.key === "ArrowDown") {
222
- event.preventDefault();
223
- event.stopPropagation();
224
- moveOptionFocus(1);
225
- return;
226
- }
227
-
228
- if (event.key === "ArrowUp") {
187
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
229
188
  event.preventDefault();
230
- event.stopPropagation();
231
- moveOptionFocus(-1);
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
+ }
232
199
  return;
233
200
  }
234
201
 
235
202
  if (event.key === "Home") {
236
203
  event.preventDefault();
237
- event.stopPropagation();
238
- focusFirstOrLastOption(false);
204
+ const nodes = getOptionNodes();
205
+ nodes[0]?.focus();
239
206
  return;
240
207
  }
241
208
 
242
209
  if (event.key === "End") {
243
210
  event.preventDefault();
244
- event.stopPropagation();
245
- focusFirstOrLastOption(true);
211
+ const nodes = getOptionNodes();
212
+ nodes[nodes.length - 1]?.focus();
246
213
  return;
247
214
  }
248
215
 
249
216
  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
- }
217
+ event.preventDefault();
218
+ const nodes = getOptionNodes();
219
+ const focused = nodes.find((n) => n === document.activeElement);
220
+ if (focused) focused.click();
258
221
  }
259
222
  }
223
+ </script>
260
224
 
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;
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);
267
247
  }
268
- canScrollAny.value = viewport.scrollHeight > viewport.clientHeight + 1;
269
248
  }
270
- </script>
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>