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.
- package/package.json +1 -1
- package/src/ui/kSelect.vue +148 -116
package/package.json
CHANGED
package/src/ui/kSelect.vue
CHANGED
|
@@ -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-
|
|
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-
|
|
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]
|
|
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"
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
<
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
<
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
194
|
-
const
|
|
195
|
-
if (!
|
|
196
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
245
|
-
|
|
238
|
+
const nodes = getOptionNodes();
|
|
239
|
+
nodes[nodes.length - 1]?.focus();
|
|
246
240
|
return;
|
|
247
241
|
}
|
|
248
242
|
|
|
249
243
|
if (event.key === "Enter") {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
if (
|
|
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
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
if (!
|
|
265
|
-
|
|
266
|
-
|
|
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
|
+
|