ketekny-ui-kit 1.0.138 → 1.0.140

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 +97 -6
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ketekny-ui-kit",
3
3
  "type": "module",
4
- "version": "1.0.138",
4
+ "version": "1.0.140",
5
5
  "description": "A Vue 3 UI component library with Tailwind CSS styling",
6
6
  "main": "index.js",
7
7
  "files": [
@@ -4,7 +4,7 @@
4
4
  {{ label }}
5
5
  </label>
6
6
 
7
- <SelectRoot v-model="internalValue" v-model:open="open" :disabled="disabled">
7
+ <SelectRoot v-model="internalValue" v-model:open="open" :disabled="disabled" :multiple="multiple">
8
8
  <SelectTrigger as-child>
9
9
  <div
10
10
  :id="computedId"
@@ -13,9 +13,36 @@
13
13
  :aria-label="label || 'Select option'"
14
14
  >
15
15
  <SelectValue
16
+ v-if="!multiple"
16
17
  :placeholder="placeholder"
17
18
  class="block pr-10 truncate text-gray-700 data-[placeholder]:text-gray-400 dark:text-slate-100 dark:data-[placeholder]:text-slate-500"
18
19
  />
20
+ <div v-else ref="selectedContainer" class="flex items-center gap-0.5 pr-10 overflow-hidden whitespace-nowrap">
21
+ <span v-if="selectedOptions.length === 0" class="text-gray-400 dark:text-slate-500">
22
+ {{ placeholder }}
23
+ </span>
24
+ <kChip
25
+ v-for="option in visibleSelectedOptions"
26
+ :key="option[optionValue]"
27
+ class="shrink-0"
28
+ size="small"
29
+ >
30
+ <span>{{ option[optionLabel] }}</span>
31
+ <button
32
+ v-if="!disabled"
33
+ type="button"
34
+ class="ml-1 text-primary/70 transition-colors hover:text-primary"
35
+ :aria-label="`Remove ${option[optionLabel]}`"
36
+ @pointerdown.stop.prevent
37
+ @click.stop.prevent="removeSelection(option[optionValue])"
38
+ >
39
+ <X class="w-3 h-3" />
40
+ </button>
41
+ </kChip>
42
+ <span v-if="hiddenSelectedCount > 0" class="text-xs text-gray-500 shrink-0 dark:text-slate-400">
43
+ +{{ hiddenSelectedCount }}...
44
+ </span>
45
+ </div>
19
46
 
20
47
  <button
21
48
  v-if="clearable && isSelectionSet && !disabled"
@@ -89,6 +116,14 @@
89
116
  </SelectPortal>
90
117
  </SelectRoot>
91
118
 
119
+ <div ref="chipMeasureContainer" class="fixed invisible flex items-center gap-0.5 pointer-events-none" aria-hidden="true">
120
+ <kChip v-for="option in selectedOptions" :key="option[optionValue]" size="small">
121
+ <span>{{ option[optionLabel] }}</span>
122
+ <X class="w-3 h-3 ml-1" />
123
+ </kChip>
124
+ <span ref="overflowLabelMeasure" class="text-xs">+{{ selectedOptions.length }}...</span>
125
+ </div>
126
+
92
127
  <div class="mt-1 text-sm text-red-500 dark:text-rose-400" v-if="hasError && error !== true && error !== ''">
93
128
  {{ error }}
94
129
  </div>
@@ -100,7 +135,7 @@
100
135
  </template>
101
136
 
102
137
  <script setup>
103
- import { computed, nextTick, ref, watch } from "vue";
138
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
104
139
  import {
105
140
  SelectContent,
106
141
  SelectItem,
@@ -113,10 +148,12 @@ import {
113
148
  SelectViewport,
114
149
  } from "reka-ui";
115
150
  import { X, ChevronDown, Check, Search, SearchX } from "@lucide/vue";
151
+ import kChip from "./kChip.vue";
116
152
 
117
153
  const props = defineProps({
118
154
  options: { type: Array, required: true },
119
- modelValue: [String, Number],
155
+ modelValue: [String, Number, Array],
156
+ multiple: { type: Boolean, default: false },
120
157
  optionValue: { type: String, default: "value" },
121
158
  optionLabel: { type: String, default: "label" },
122
159
  label: String,
@@ -136,6 +173,11 @@ const open = ref(false);
136
173
  const searchQuery = ref("");
137
174
  const searchInput = ref(null);
138
175
  const viewportRef = ref(null);
176
+ const selectedContainer = ref(null);
177
+ const chipMeasureContainer = ref(null);
178
+ const overflowLabelMeasure = ref(null);
179
+ const visibleSelectedCount = ref(0);
180
+ let selectedContainerObserver;
139
181
  const generatedId = `select-${Math.random().toString(36).substr(2, 9)}`;
140
182
  const defaultStyle = "w-full px-3 py-2 border rounded-lg transition shadow-sm focus-visible:outline-none text-gray-700 focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:border-primary bg-white placeholder-gray-400 dark:bg-slate-800 dark:text-slate-100 dark:border-slate-600 dark:placeholder-slate-500";
141
183
  const errorStyle = "border-red-500 focus-visible:ring focus-visible:ring-red-300 dark:border-rose-500 dark:focus-visible:ring-rose-500/20";
@@ -177,15 +219,59 @@ function normalizeDropdownHeight(value) {
177
219
 
178
220
  const computedId = computed(() => (props.id != null && props.id !== "" ? props.id : generatedId));
179
221
  const hasError = computed(() => props.error != null && props.error !== false);
180
- const isSelectionSet = computed(() => props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== "");
222
+ const isSelectionSet = computed(() => {
223
+ if (props.multiple) return Array.isArray(props.modelValue) && props.modelValue.length > 0;
224
+ return props.modelValue !== null && props.modelValue !== undefined && props.modelValue !== "";
225
+ });
181
226
  const resolvedDropdownHeight = computed(() => normalizeDropdownHeight(props.dropdownHeight));
227
+ const selectedOptions = computed(() => {
228
+ if (!props.multiple || !Array.isArray(props.modelValue)) return [];
229
+ return props.modelValue
230
+ .map((value) => props.options.find((option) => option?.[props.optionValue] === value))
231
+ .filter(Boolean);
232
+ });
233
+ const visibleSelectedOptions = computed(() => selectedOptions.value.slice(0, visibleSelectedCount.value));
234
+ const hiddenSelectedCount = computed(() => selectedOptions.value.length - visibleSelectedOptions.value.length);
235
+
236
+ function updateVisibleSelectedCount() {
237
+ if (!props.multiple || !selectedContainer.value || !chipMeasureContainer.value) return;
238
+
239
+ const chipElements = Array.from(chipMeasureContainer.value.children).slice(0, selectedOptions.value.length);
240
+ const availableWidth = selectedContainer.value.clientWidth;
241
+ const overflowLabelWidth = overflowLabelMeasure.value?.offsetWidth ?? 0;
242
+ const gap = 2;
243
+ let usedWidth = 0;
244
+ let count = 0;
245
+
246
+ for (const chip of chipElements) {
247
+ const nextWidth = usedWidth + (count > 0 ? gap : 0) + chip.offsetWidth;
248
+ const remainingAfterNext = selectedOptions.value.length - count - 1;
249
+ const reservedWidth = remainingAfterNext > 0 ? gap + overflowLabelWidth : 0;
250
+ if (nextWidth + reservedWidth > availableWidth) break;
251
+ usedWidth = nextWidth;
252
+ count += 1;
253
+ }
254
+
255
+ visibleSelectedCount.value = count;
256
+ }
257
+
258
+ onMounted(() => {
259
+ selectedContainerObserver = new ResizeObserver(updateVisibleSelectedCount);
260
+ if (selectedContainer.value) selectedContainerObserver.observe(selectedContainer.value);
261
+ nextTick(updateVisibleSelectedCount);
262
+ });
263
+
264
+ onBeforeUnmount(() => selectedContainerObserver?.disconnect());
265
+
266
+ watch(selectedOptions, () => nextTick(updateVisibleSelectedCount), { deep: true });
182
267
 
183
268
  const internalValue = computed({
184
269
  get() {
270
+ if (props.multiple) return Array.isArray(props.modelValue) ? props.modelValue : [];
185
271
  return props.modelValue === null ? undefined : props.modelValue;
186
272
  },
187
273
  set(val) {
188
- emit("update:modelValue", val ?? null);
274
+ emit("update:modelValue", props.multiple ? (Array.isArray(val) ? val : []) : (val ?? null));
189
275
  },
190
276
  });
191
277
 
@@ -219,10 +305,15 @@ watch(open, (val) => {
219
305
  });
220
306
 
221
307
  function clearSelection() {
222
- emit("update:modelValue", null);
308
+ emit("update:modelValue", props.multiple ? [] : null);
223
309
  open.value = false;
224
310
  }
225
311
 
312
+ function removeSelection(value) {
313
+ if (props.disabled || !Array.isArray(props.modelValue)) return;
314
+ emit("update:modelValue", props.modelValue.filter((selectedValue) => selectedValue !== value));
315
+ }
316
+
226
317
  function getOptionNodes() {
227
318
  const el = viewportRef.value?.$el ?? viewportRef.value;
228
319
  if (!el) return [];