ketekny-ui-kit 1.0.18 → 1.0.20

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 +148 -116
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.20",
5
5
  "description": "A Vue 3 UI component library with Tailwind CSS styling",
6
6
  "main": "index.js",
7
7
  "files": [
@@ -17,86 +17,88 @@
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">
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
- />
40
+ <div v-if="searchable" class="sticky top-0 z-10 p-1.5 border-b border-gray-100 bg-white">
41
+ <div class="relative">
42
+ <Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
43
+ <input
44
+ ref="searchInput"
45
+ type="text"
46
+ v-model="searchQuery"
47
+ placeholder="Αναζήτηση..."
48
+ class="w-full pl-7 pr-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
+ role="searchbox"
50
+ aria-label="Search options"
51
+ @keydown="handleSearchKeydown"
52
+ @keyup.stop
53
+ />
54
+ </div>
53
55
  </div>
54
56
 
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"
57
+ <SelectScrollUpButton class="flex items-center justify-center py-1 text-gray-400 hover:text-gray-600 cursor-default bg-white border-b border-gray-100">
58
+ <ChevronUp class="w-4 h-4" />
59
+ </SelectScrollUpButton>
60
+
61
+ <SelectViewport
62
+ ref="viewportRef"
63
+ class="p-1"
64
+ :style="{ maxHeight: dropdownHeight }"
65
+ @keydown="handleViewportKeydown"
66
+ >
67
+ <SelectItem
68
+ v-for="(option, index) in filteredOptions"
69
+ :key="option[optionValue]"
70
+ :value="option[optionValue]"
71
+ class="select-item relative flex items-center gap-1.5 rounded-[4px] py-2.5 pl-7 pr-3 text-sm cursor-pointer outline-none select-none text-slate-700
72
+ data-[highlighted]:bg-primary data-[highlighted]:text-white
73
+ data-[state=checked]:bg-primary/5 data-[state=checked]:text-primary
74
+ data-[state=checked]:data-[highlighted]:bg-primary data-[state=checked]:data-[highlighted]:text-white"
61
75
  >
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>
76
+ <span class="absolute left-2 flex items-center justify-center w-4 h-4 shrink-0">
77
+ <SelectItemIndicator>
78
+ <Check class="w-3.5 h-3.5" />
79
+ </SelectItemIndicator>
80
+ </span>
81
+ <SelectItemText class="truncate">{{ option[optionLabel] }}</SelectItemText>
82
+ </SelectItem>
83
+
84
+ <div v-if="filteredOptions.length === 0" class="flex flex-col items-center gap-1.5 px-3 py-4 text-sm text-gray-400 text-center">
85
+ <SearchX class="w-5 h-5 text-gray-300" />
86
+ <span>Δεν βρέθηκαν επιλογές</span>
87
+ </div>
88
+ </SelectViewport>
89
+
90
+ <SelectScrollDownButton class="flex items-center justify-center py-1 text-gray-400 hover:text-gray-600 cursor-default bg-white border-t border-gray-100">
91
+ <ChevronDown class="w-4 h-4" />
92
+ </SelectScrollDownButton>
91
93
  </SelectContent>
92
94
  </SelectPortal>
93
95
  </SelectRoot>
94
96
 
95
- <div class="mt-1 text-red-500" v-if="hasError && error !== true && error !== ''">
97
+ <div class="mt-1 text-sm text-red-500" v-if="hasError && error !== true && error !== ''">
96
98
  {{ error }}
97
99
  </div>
98
100
 
99
- <div class="mt-1 text-gray-500" v-if="info">
101
+ <div class="mt-1 text-sm text-gray-500" v-if="info">
100
102
  {{ info }}
101
103
  </div>
102
104
  </div>
@@ -117,7 +119,7 @@ import {
117
119
  SelectValue,
118
120
  SelectViewport,
119
121
  } from "reka-ui";
120
- import { X, ChevronDown, ChevronUp, Check } from "lucide-vue-next";
122
+ import { X, ChevronDown, ChevronUp, Check, Search, SearchX } from "lucide-vue-next";
121
123
 
122
124
  const props = defineProps({
123
125
  options: { type: Array, required: true },
@@ -130,6 +132,7 @@ const props = defineProps({
130
132
  disabled: { type: Boolean, default: false },
131
133
  placeholder: { type: String, default: "Επιλέξτε μία τιμή" },
132
134
  id: { type: String, default: null },
135
+ dropdownHeight: { type: String, default: "min(400px, 40vh)" },
133
136
  clearable: { type: Boolean, default: true },
134
137
  searchable: { type: Boolean, default: true },
135
138
  });
@@ -139,16 +142,13 @@ const emit = defineEmits(["update:modelValue"]);
139
142
  const open = ref(false);
140
143
  const searchQuery = ref("");
141
144
  const searchInput = ref(null);
142
- const canScrollAny = ref(false);
145
+ const viewportRef = ref(null);
143
146
  const generatedId = `select-${Math.random().toString(36).substr(2, 9)}`;
144
- const viewportMaxHeight = "400px";
145
147
  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
148
  const errorStyle = "border-red-500 focus:ring focus:ring-red-300";
147
149
  const disabledStyle = "!bg-gray-100 !text-gray-400 !cursor-not-allowed";
148
150
 
149
151
  const computedId = computed(() => (props.id != null && props.id !== "" ? props.id : generatedId));
150
- const dropdownId = computed(() => `${computedId.value}-listbox`);
151
-
152
152
  const hasError = computed(() => props.error != null && props.error !== false);
153
153
  const isSelectionSet = computed(() => props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== "");
154
154
 
@@ -161,28 +161,33 @@ const internalValue = computed({
161
161
  },
162
162
  });
163
163
 
164
+ const normalizedOptions = computed(() =>
165
+ props.options.map((o) => ({
166
+ option: o,
167
+ normalized: String(o?.[props.optionLabel] ?? "").toLowerCase(),
168
+ }))
169
+ );
170
+
164
171
  const filteredOptions = computed(() => {
165
172
  if (!props.searchable) return props.options;
166
173
  const q = searchQuery.value.trim().toLowerCase();
167
174
  if (!q) return props.options;
168
- return props.options.filter((o) => String(o?.[props.optionLabel] ?? "").toLowerCase().includes(q));
175
+ return normalizedOptions.value
176
+ .filter(({ normalized }) => normalized.includes(q))
177
+ .map(({ option }) => option);
169
178
  });
170
179
 
171
180
  watch(open, (val) => {
172
181
  if (val) {
173
182
  nextTick(() => {
174
183
  if (props.searchable) searchInput.value?.focus();
175
- updateScrollState();
184
+ const el = viewportRef.value?.$el ?? viewportRef.value;
185
+ const checked = el?.querySelector('[data-state="checked"]');
186
+ checked?.scrollIntoView({ block: "nearest" });
176
187
  });
177
188
  return;
178
189
  }
179
190
  searchQuery.value = "";
180
- canScrollAny.value = false;
181
- });
182
-
183
- watch(filteredOptions, () => {
184
- if (!open.value) return;
185
- nextTick(() => updateScrollState());
186
191
  });
187
192
 
188
193
  function clearSelection() {
@@ -190,81 +195,108 @@ function clearSelection() {
190
195
  open.value = false;
191
196
  }
192
197
 
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();
198
+ function getOptionNodes() {
199
+ const el = viewportRef.value?.$el ?? viewportRef.value;
200
+ if (!el) return [];
201
+ return Array.from(el.querySelectorAll('[role="option"]:not([data-disabled])'));
211
202
  }
212
203
 
213
204
  function handleSearchKeydown(event) {
205
+ // Always stop propagation to prevent reka-ui typeahead on character keys
206
+ event.stopPropagation();
207
+
214
208
  if (event.key === "Escape") {
215
209
  event.preventDefault();
216
- event.stopPropagation();
217
210
  open.value = false;
218
211
  return;
219
212
  }
220
213
 
221
- if (event.key === "ArrowDown") {
222
- event.preventDefault();
223
- event.stopPropagation();
224
- moveOptionFocus(1);
225
- return;
226
- }
227
-
228
- if (event.key === "ArrowUp") {
214
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
229
215
  event.preventDefault();
230
- event.stopPropagation();
231
- moveOptionFocus(-1);
216
+ const nodes = getOptionNodes();
217
+ if (!nodes.length) return;
218
+ const activeIndex = nodes.findIndex((n) => n === document.activeElement);
219
+ if (event.key === "ArrowDown") {
220
+ const next = activeIndex < nodes.length - 1 ? nodes[activeIndex + 1] : nodes[0];
221
+ next.focus();
222
+ } else {
223
+ const prev = activeIndex > 0 ? nodes[activeIndex - 1] : nodes[nodes.length - 1];
224
+ prev.focus();
225
+ }
232
226
  return;
233
227
  }
234
228
 
235
229
  if (event.key === "Home") {
236
230
  event.preventDefault();
237
- event.stopPropagation();
238
- focusFirstOrLastOption(false);
231
+ const nodes = getOptionNodes();
232
+ nodes[0]?.focus();
239
233
  return;
240
234
  }
241
235
 
242
236
  if (event.key === "End") {
243
237
  event.preventDefault();
244
- event.stopPropagation();
245
- focusFirstOrLastOption(true);
238
+ const nodes = getOptionNodes();
239
+ nodes[nodes.length - 1]?.focus();
246
240
  return;
247
241
  }
248
242
 
249
243
  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
- }
244
+ event.preventDefault();
245
+ const nodes = getOptionNodes();
246
+ const focused = nodes.find((n) => n === document.activeElement);
247
+ if (focused) focused.click();
258
248
  }
259
249
  }
260
250
 
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;
251
+ function handleViewportKeydown(event) {
252
+ if (!props.searchable || !searchInput.value || event.target === searchInput.value) return;
253
+ const isPrintable = event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
254
+ if (!isPrintable) return;
255
+ event.preventDefault();
256
+ searchQuery.value += event.key;
257
+ nextTick(() => searchInput.value?.focus());
269
258
  }
270
259
  </script>
260
+
261
+ <style scoped>
262
+ .select-content {
263
+ transform-origin: var(--reka-select-content-transform-origin);
264
+ width: var(--reka-popper-anchor-width);
265
+ }
266
+
267
+ .select-content[data-state="open"] {
268
+ animation: selectOpen 0.15s cubic-bezier(0.16, 1, 0.3, 1);
269
+ }
270
+
271
+ .select-content[data-state="closed"] {
272
+ animation: selectClose 0.1s ease-in;
273
+ }
274
+
275
+ @keyframes selectOpen {
276
+ from {
277
+ opacity: 0;
278
+ transform: translateY(-6px) scale(0.97);
279
+ }
280
+ to {
281
+ opacity: 1;
282
+ transform: translateY(0) scale(1);
283
+ }
284
+ }
285
+
286
+ @keyframes selectClose {
287
+ from {
288
+ opacity: 1;
289
+ transform: translateY(0) scale(1);
290
+ }
291
+ to {
292
+ opacity: 0;
293
+ transform: translateY(-6px) scale(0.97);
294
+ }
295
+ }
296
+
297
+ .select-item {
298
+ transition: background-color 80ms, color 80ms;
299
+ }
300
+
301
+ </style>
302
+