cynx-lib 1.3.52 → 1.3.53

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cynx-lib",
3
- "version": "1.3.52",
3
+ "version": "1.3.53",
4
4
  "type": "module",
5
5
  "main": "./index.cjs",
6
6
  "module": "./index.js",
@@ -8,12 +8,18 @@ import {
8
8
  nextTick,
9
9
  useSlots,
10
10
  } from "vue";
11
- import { ChevronDown, Check, X, Search } from "lucide-vue-next";
11
+ import { ChevronDown, Check, X, Plus } from "lucide-vue-next";
12
12
 
13
13
  export interface SelectOption {
14
14
  value: string | number;
15
- label: string;
15
+ label?: string | number;
16
+ name?: string | number;
17
+ search?: string | number;
18
+ employee_id?: string | number;
19
+ code?: string | number;
20
+ title?: string | number;
16
21
  disabled?: boolean;
22
+ displayLabel?: string | number;
17
23
  [key: string]: any;
18
24
  }
19
25
 
@@ -29,78 +35,63 @@ const SIZES: Record<
29
35
  itemPadding: string;
30
36
  }
31
37
  > = {
32
- xs: {
33
- height: 24,
34
- fontSize: 11,
35
- paddingH: 6,
36
- searchHeight: 22,
37
- searchFont: 10.5,
38
- itemMinHeight: 20,
39
- itemPadding: "3px 6px",
40
- },
41
- sm: {
42
- height: 28,
43
- fontSize: 11.5,
44
- paddingH: 8,
45
- searchHeight: 26,
46
- searchFont: 11.5,
47
- itemMinHeight: 24,
48
- itemPadding: "4px 8px",
49
- },
50
- md: {
51
- height: 32,
52
- fontSize: 13,
53
- paddingH: 10,
54
- searchHeight: 30,
55
- searchFont: 12.5,
56
- itemMinHeight: 28,
57
- itemPadding: "6px 10px",
58
- },
59
- lg: {
60
- height: 36,
61
- fontSize: 14,
62
- paddingH: 14,
63
- searchHeight: 34,
64
- searchFont: 13,
65
- itemMinHeight: 32,
66
- itemPadding: "8px 12px",
67
- },
38
+ xs: { height: 24, fontSize: 11, paddingH: 6, searchHeight: 22, searchFont: 10.5, itemMinHeight: 18, itemPadding: "3px 5px" },
39
+ sm: { height: 28, fontSize: 11.5, paddingH: 8, searchHeight: 26, searchFont: 11.5, itemMinHeight: 22, itemPadding: "4.5px 7px" },
40
+ md: { height: 32, fontSize: 13, paddingH: 10, searchHeight: 32, searchFont: 12.5, itemMinHeight: 28, itemPadding: "8px 10px" },
41
+ lg: { height: 36, fontSize: 14, paddingH: 14, searchHeight: 34, searchFont: 13, itemMinHeight: 32, itemPadding: "9px 12px" },
68
42
  };
69
43
 
44
+ const KEYFRAMES = `
45
+ @keyframes selFadeInDown{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
46
+ @keyframes selFadeInUp{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
47
+ `;
48
+ const _id = "cynx-select-styles";
49
+
50
+ function ensureStyles() {
51
+ if (typeof document === "undefined") return;
52
+ if (document.getElementById(_id)) return;
53
+ const tag = document.createElement("style");
54
+ tag.id = _id;
55
+ tag.textContent = KEYFRAMES;
56
+ document.head.appendChild(tag);
57
+ }
58
+
70
59
  const props = withDefaults(
71
60
  defineProps<{
72
- modelValue?: string | number | (string | number)[];
73
61
  options?: SelectOption[];
62
+ modelValue?: any;
63
+ value?: any;
74
64
  placeholder?: string;
75
65
  disabled?: boolean;
76
66
  multi?: boolean;
77
67
  searchable?: boolean;
78
68
  clearable?: boolean;
79
- selectAll?: boolean;
80
- selectAllText?: string;
81
69
  size?: "xs" | "sm" | "md" | "lg";
82
- showCount?: boolean | ((count: number) => string);
83
- rounded?: boolean | string | number;
84
- searchMode?: "includes" | "starts_with";
85
- className?: string;
86
70
  style?: Record<string, any>;
71
+ rounded?: boolean | string | number;
72
+ showCount?: boolean | ((count: number) => string);
73
+ searchMode?: "includes" | "starts_with" | "word_start";
74
+ filterOption?: (option: SelectOption, query: string) => boolean;
75
+ creatable?: boolean;
76
+ createLabel?: (query: string) => string;
87
77
  }>(),
88
78
  {
89
- modelValue: undefined,
90
79
  options: () => [],
80
+ modelValue: undefined,
81
+ value: undefined,
91
82
  placeholder: "Select...",
92
83
  disabled: false,
93
84
  multi: false,
94
85
  searchable: false,
95
86
  clearable: false,
96
- selectAll: false,
97
- selectAllText: "All Select",
98
87
  size: "md",
99
- showCount: false,
88
+ style: () => ({}),
100
89
  rounded: false,
90
+ showCount: false,
101
91
  searchMode: "includes",
102
- className: "",
103
- style: () => ({}),
92
+ filterOption: undefined,
93
+ creatable: false,
94
+ createLabel: undefined,
104
95
  }
105
96
  );
106
97
 
@@ -114,60 +105,57 @@ const emit = defineEmits<{
114
105
  const slots = useSlots();
115
106
  const open = ref(false);
116
107
  const query = ref("");
108
+ const pos = ref({ top: 0, left: 0, width: 0, openUp: false, maxHeight: 200 });
117
109
  const wrapRef = ref<HTMLElement | null>(null);
118
110
  const dropRef = ref<HTMLElement | null>(null);
119
- const searchInputRef = ref<HTMLInputElement | null>(null);
120
-
121
- const pos = ref({
122
- top: 0,
123
- left: 0,
124
- width: 0,
125
- openUp: false,
126
- maxHeight: 220,
127
- });
111
+ const inputRef = ref<HTMLInputElement | null>(null);
112
+
113
+ ensureStyles();
128
114
 
129
115
  const s = computed(() => SIZES[props.size] || SIZES.md);
130
116
 
131
- const selectedValues = computed<(string | number)[]>(() => {
117
+ const currentVal = computed(() => {
118
+ return props.modelValue !== undefined ? props.modelValue : props.value;
119
+ });
120
+
121
+ const selectedValues = computed<any[]>(() => {
122
+ const v = currentVal.value;
132
123
  if (props.multi) {
133
- if (Array.isArray(props.modelValue)) return props.modelValue;
134
- if (props.modelValue !== undefined && props.modelValue !== null && props.modelValue !== "") {
135
- return [props.modelValue as string | number];
136
- }
137
- return [];
138
- }
139
- if (props.modelValue !== undefined && props.modelValue !== null && props.modelValue !== "") {
140
- return [props.modelValue as string | number];
124
+ return Array.isArray(v) ? v : [];
141
125
  }
142
- return [];
126
+ return v !== undefined && v !== null && v !== "" ? [v] : [];
143
127
  });
144
128
 
145
- const selectedOptions = computed<SelectOption[]>(() => {
146
- return selectedValues.value
147
- .map((v) => props.options.find((o) => String(o.value) === String(v)))
148
- .filter((o): o is SelectOption => Boolean(o));
149
- });
129
+ function getLabel(optVal: any) {
130
+ const opt = props.options.find((o) => o.value === optVal);
131
+ return opt ? (opt.displayLabel || opt.label) : optVal;
132
+ }
150
133
 
151
- const isAllSelected = computed(() => {
152
- if (!props.options.length) return false;
153
- const enabledOptions = props.options.filter((o) => !o.disabled);
154
- if (!enabledOptions.length) return false;
155
- return enabledOptions.every((o) =>
156
- selectedValues.value.some((v) => String(v) === String(o.value))
157
- );
158
- });
134
+ function isSelected(optVal: any) {
135
+ const v = currentVal.value;
136
+ return props.multi ? (Array.isArray(v) && v.includes(optVal)) : v === optVal;
137
+ }
159
138
 
160
- const filteredOptions = computed<SelectOption[]>(() => {
161
- if (!query.value) return props.options;
162
- const q = query.value.toLowerCase().trim();
163
- return props.options.filter((o) => {
164
- const label = String(o.label ?? "").toLowerCase();
165
- const val = String(o.value ?? "").toLowerCase();
166
- if (props.searchMode === "starts_with") {
167
- return label.startsWith(q) || val.startsWith(q);
139
+ const radiusVal = computed(() => {
140
+ const curSize = s.value;
141
+ if (props.rounded) {
142
+ if (typeof props.rounded === "string") {
143
+ return props.rounded.endsWith("px") ||
144
+ props.rounded.endsWith("rem") ||
145
+ props.rounded.startsWith("var")
146
+ ? props.rounded
147
+ : `${props.rounded}px`;
168
148
  }
169
- return label.includes(q) || val.includes(q);
170
- });
149
+ if (typeof props.rounded === "number") {
150
+ return `${props.rounded}px`;
151
+ }
152
+ return "6px";
153
+ }
154
+ return curSize.height <= 26
155
+ ? "4px"
156
+ : curSize.height <= 28
157
+ ? "6px"
158
+ : "var(--rmd,var(--rads, 8px))";
171
159
  });
172
160
 
173
161
  const calcPosition = () => {
@@ -178,13 +166,15 @@ const calcPosition = () => {
178
166
  const spaceBelow = window.innerHeight - r.bottom;
179
167
  const spaceAbove = r.top;
180
168
 
181
- const openUp =
182
- spaceBelow < Math.min(dropHeight, 220) + 12 && spaceAbove > spaceBelow;
169
+ // If not enough space below (< dropHeight + 10) and more space above, open up
170
+ const openUp = spaceBelow < Math.min(dropHeight, 220) + 12 && spaceAbove > spaceBelow;
183
171
 
172
+ // Dynamic max-height for inner list to prevent overflow
184
173
  const availableSpace = openUp ? spaceAbove - 16 : spaceBelow - 16;
174
+ const isSearchActive = props.searchable || props.creatable;
185
175
  const calculatedMaxHeight = Math.max(
186
176
  90,
187
- Math.min(240, availableSpace - (props.searchable || props.selectAll ? 48 : 12))
177
+ Math.min(220, availableSpace - (isSearchActive ? 46 : 14))
188
178
  );
189
179
 
190
180
  let top: number;
@@ -215,166 +205,148 @@ const handleWindowClick = (e: MouseEvent) => {
215
205
  const inTrigger = wrapRef.value && wrapRef.value.contains(target);
216
206
  const inDrop = dropRef.value && dropRef.value.contains(target);
217
207
  if (!inTrigger && !inDrop) {
218
- closeDropdown();
208
+ open.value = false;
209
+ emit("visible-change", false);
219
210
  }
220
211
  };
221
212
 
222
- const openDropdown = () => {
223
- if (props.disabled) return;
224
- open.value = true;
225
- calcPosition();
226
- emit("visible-change", true);
227
- nextTick(() => {
213
+ watch(open, (isOpen) => {
214
+ if (isOpen) {
228
215
  calcPosition();
229
- if (props.searchable && searchInputRef.value) {
230
- searchInputRef.value.focus();
231
- }
232
- });
233
- };
216
+ window.addEventListener("scroll", calcPosition, true);
217
+ window.addEventListener("resize", calcPosition);
218
+ document.addEventListener("mousedown", handleWindowClick, true);
219
+ nextTick(() => {
220
+ calcPosition();
221
+ if ((props.searchable || props.creatable) && inputRef.value) {
222
+ inputRef.value.focus();
223
+ }
224
+ });
225
+ } else {
226
+ query.value = "";
227
+ window.removeEventListener("scroll", calcPosition, true);
228
+ window.removeEventListener("resize", calcPosition);
229
+ document.removeEventListener("mousedown", handleWindowClick, true);
230
+ }
231
+ });
234
232
 
235
- const closeDropdown = () => {
236
- open.value = false;
237
- query.value = "";
238
- emit("visible-change", false);
239
- };
233
+ onBeforeUnmount(() => {
234
+ window.removeEventListener("scroll", calcPosition, true);
235
+ window.removeEventListener("resize", calcPosition);
236
+ document.removeEventListener("mousedown", handleWindowClick, true);
237
+ });
240
238
 
241
- const toggleOpen = () => {
242
- if (open.value) {
243
- closeDropdown();
244
- } else {
245
- openDropdown();
239
+ const filtered = computed(() => {
240
+ if (!query.value) return props.options;
241
+ const q = query.value.toLowerCase().trim();
242
+ if (typeof props.filterOption === "function") {
243
+ return props.options.filter((o) => props.filterOption!(o, q));
246
244
  }
247
- };
245
+ const isStartsWith = props.searchMode === "starts_with" || props.searchMode === "word_start";
246
+ return props.options.filter((o) => {
247
+ const targets: string[] = [];
248
+ if (o.label != null) targets.push(String(o.label));
249
+ if (o.name != null && o.name !== o.label) targets.push(String(o.name));
250
+ if (o.search != null) targets.push(String(o.search));
251
+ if (o.employee_id != null) targets.push(String(o.employee_id));
252
+ if (o.code != null) targets.push(String(o.code));
253
+ if (o.title != null) targets.push(String(o.title));
254
+ if (targets.length === 0 && o.value != null) {
255
+ targets.push(String(o.value));
256
+ }
248
257
 
249
- const isOptionSelected = (optVal: string | number) => {
250
- return selectedValues.value.some((v) => String(v) === String(optVal));
251
- };
258
+ for (const t of targets) {
259
+ const str = t.toLowerCase().trim();
260
+ if (isStartsWith) {
261
+ if (str.startsWith(q)) return true;
262
+ const words = str.split(/[\s\-.,()\/_:]+/);
263
+ if (words.some((w) => w.startsWith(q))) return true;
264
+ } else {
265
+ if (str.includes(q)) return true;
266
+ }
267
+ }
268
+ return false;
269
+ });
270
+ });
252
271
 
253
- const toggleOption = (optVal: string | number) => {
272
+ function handleOpen() {
273
+ if (props.disabled) return;
274
+ if (!open.value && wrapRef.value) {
275
+ calcPosition();
276
+ }
277
+ open.value = !open.value;
278
+ emit("visible-change", open.value);
279
+ }
280
+
281
+ function toggleOption(optVal: any) {
254
282
  if (props.multi) {
255
- const current = [...selectedValues.value];
256
- const index = current.findIndex((v) => String(v) === String(optVal));
257
- let next: (string | number)[];
258
- if (index >= 0) {
259
- next = current.filter((_, i) => i !== index);
260
- } else {
261
- next = [...current, optVal];
262
- }
283
+ const arr = Array.isArray(currentVal.value) ? currentVal.value : [];
284
+ const next = arr.includes(optVal)
285
+ ? arr.filter((v: any) => v !== optVal)
286
+ : [...arr, optVal];
263
287
  emit("update:modelValue", next);
264
288
  emit("change", next);
265
289
  } else {
266
290
  emit("update:modelValue", optVal);
267
291
  emit("change", optVal);
268
- closeDropdown();
269
- }
270
- };
271
-
272
- const toggleSelectAll = () => {
273
- if (!props.multi) return;
274
- if (isAllSelected.value) {
275
- emit("update:modelValue", []);
276
- emit("change", []);
277
- } else {
278
- const allVals = props.options
279
- .filter((o) => !o.disabled)
280
- .map((o) => o.value);
281
- emit("update:modelValue", allVals);
282
- emit("change", allVals);
292
+ open.value = false;
293
+ emit("visible-change", false);
283
294
  }
284
- };
295
+ }
285
296
 
286
- const removeTag = (e: MouseEvent, val: string | number) => {
297
+ function removeTag(e: MouseEvent, valToRemove: any) {
287
298
  e.stopPropagation();
288
- const next = selectedValues.value.filter((v) => String(v) !== String(val));
299
+ const arr = Array.isArray(currentVal.value) ? currentVal.value : [];
300
+ const next = arr.filter((v: any) => v !== valToRemove);
289
301
  emit("update:modelValue", next);
290
302
  emit("change", next);
291
- };
303
+ }
292
304
 
293
- const handleClear = (e: MouseEvent) => {
305
+ function handleClear(e: MouseEvent) {
294
306
  e.stopPropagation();
295
307
  const next = props.multi ? [] : "";
296
308
  emit("update:modelValue", next);
297
309
  emit("change", next);
298
310
  emit("clear");
299
- };
300
-
301
- const getOptionLabel = (val: string | number) => {
302
- const opt = props.options.find((o) => String(o.value) === String(val));
303
- return opt ? opt.label : String(val);
304
- };
305
-
306
- const displaySummaryText = computed(() => {
307
- if (!selectedValues.value.length) return "";
308
- if (typeof props.showCount === "function") {
309
- return props.showCount(selectedValues.value.length);
310
- }
311
- if (props.showCount) {
312
- if (selectedValues.value.length === 1) {
313
- return getOptionLabel(selectedValues.value[0]);
314
- }
315
- if (isAllSelected.value) {
316
- return `All Selected (${selectedValues.value.length})`;
317
- }
318
- return `Selected (${selectedValues.value.length})`;
319
- }
320
- return "";
321
- });
322
-
323
- watch(open, (isOpen) => {
324
- if (isOpen) {
325
- window.addEventListener("scroll", calcPosition, true);
326
- window.addEventListener("resize", calcPosition);
327
- document.addEventListener("mousedown", handleWindowClick, true);
328
- } else {
329
- window.removeEventListener("scroll", calcPosition, true);
330
- window.removeEventListener("resize", calcPosition);
331
- document.removeEventListener("mousedown", handleWindowClick, true);
332
- }
333
- });
334
-
335
- onBeforeUnmount(() => {
336
- window.removeEventListener("scroll", calcPosition, true);
337
- window.removeEventListener("resize", calcPosition);
338
- document.removeEventListener("mousedown", handleWindowClick, true);
339
- });
311
+ }
340
312
 
341
- const radiusStyle = computed(() => {
342
- if (props.rounded) {
343
- if (typeof props.rounded === "string") {
344
- return props.rounded.endsWith("px") ||
345
- props.rounded.endsWith("rem") ||
346
- props.rounded.startsWith("var")
347
- ? props.rounded
348
- : `${props.rounded}px`;
313
+ function handleInputKeyDown(e: KeyboardEvent) {
314
+ if (e.key === "Enter") {
315
+ e.preventDefault();
316
+ e.stopPropagation();
317
+ const trimmed = query.value.trim();
318
+ if (!trimmed) return;
319
+ const exact = filtered.value.find(
320
+ (o) => String(o.label || o.value || "").toLowerCase() === trimmed.toLowerCase()
321
+ );
322
+ if (exact && !exact.disabled) {
323
+ toggleOption(exact.value);
324
+ query.value = "";
325
+ } else if (props.creatable) {
326
+ toggleOption(trimmed);
327
+ query.value = "";
328
+ } else if (filtered.value.length > 0 && !filtered.value[0].disabled) {
329
+ toggleOption(filtered.value[0].value);
330
+ query.value = "";
349
331
  }
350
- if (typeof props.rounded === "number") return `${props.rounded}px`;
351
- return "6px";
352
332
  }
353
- return s.value.height <= 26
354
- ? "4px"
355
- : s.value.height <= 28
356
- ? "6px"
357
- : "var(--rads, 8px)";
358
- });
333
+ }
359
334
  </script>
360
335
 
361
336
  <template>
362
337
  <div
363
338
  ref="wrapRef"
364
- class="cynx-select-root"
365
- :class="className"
366
339
  :style="{
367
340
  position: 'relative',
368
341
  width: '100%',
369
342
  boxSizing: 'border-box',
343
+ fontFamily: 'var(--f,var(--sans, inherit))',
370
344
  opacity: disabled ? 0.5 : 1,
371
345
  pointerEvents: disabled ? 'none' : undefined,
372
346
  ...style,
373
347
  }"
374
348
  >
375
- <!-- Select Trigger -->
376
349
  <div
377
- class="cynx-select-trigger"
378
350
  :style="{
379
351
  width: '100%',
380
352
  boxSizing: 'border-box',
@@ -384,56 +356,56 @@ const radiusStyle = computed(() => {
384
356
  gap: '4px',
385
357
  padding: `0 ${s.paddingH}px`,
386
358
  height: `${s.height}px`,
387
- background: 'var(--sf, var(--surface, #ffffff))',
388
- border: `1px solid ${open ? 'var(--accent, #12675D)' : 'var(--border, #e2e8f0)'}`,
389
- borderRadius: radiusStyle,
359
+ background: 'var(--sf,var(--surface, #ffffff))',
360
+ border: `1px solid ${open ? 'var(--accent, #f99e2c)' : 'var(--bd2,var(--border, #e2e8f0))'}`,
361
+ borderRadius: radiusVal,
390
362
  cursor: 'pointer',
363
+ fontFamily: 'var(--f,var(--sans,inherit))',
391
364
  fontSize: `${s.fontSize}px`,
392
365
  lineHeight: 1.2,
393
- color: 'var(--primary-text, #0f172a)',
366
+ color: 'var(--t,var(--primary-text, #0f172a))',
394
367
  outline: 'none',
395
- transition: 'border-color 0.15s, box-shadow 0.15s',
368
+ transition: 'border-color var(--tr,.15s), box-shadow var(--tr,.15s)',
396
369
  boxShadow: open
397
- ? '0 0 0 2px rgba(18, 103, 93, 0.15)'
398
- : 'var(--shadow, 0 1px 2px rgba(0, 0, 0, 0.04))',
370
+ ? 'var(--infd, rgba(37,99,235,.1))'
371
+ : 'var(--shadow,0 1px 3px rgba(20,22,41,.1))',
399
372
  }"
400
- @click="toggleOpen"
373
+ @click="handleOpen"
401
374
  >
402
- <!-- Prefix Slot -->
403
- <slot name="prefix" />
375
+ <slot v-if="slots.prefix" name="prefix" />
404
376
 
405
- <!-- Content / Label Area -->
406
377
  <div
407
378
  style="
408
379
  flex: 1;
409
380
  min-width: 0;
410
381
  display: flex;
411
382
  align-items: center;
412
- overflow: hidden;
413
383
  "
414
384
  >
415
- <!-- Custom Label Slot -->
416
385
  <slot
417
386
  v-if="slots.label && selectedValues.length > 0"
418
387
  name="label"
419
388
  :value="multi ? selectedValues : selectedValues[0]"
420
- :selected-options="selectedOptions"
421
- :selected-option="selectedOptions[0]"
422
389
  />
423
390
 
424
- <!-- Multi Select Tags / Summary -->
425
391
  <template v-else-if="multi && selectedValues.length > 0">
426
392
  <span
427
- v-if="displaySummaryText"
393
+ v-if="showCount"
428
394
  style="
429
395
  font-weight: 500;
430
396
  white-space: nowrap;
431
397
  overflow: hidden;
432
398
  text-overflow: ellipsis;
433
- color: var(--primary-text, #0f172a);
399
+ color: var(--t,var(--primary-text, #0f172a));
434
400
  "
435
401
  >
436
- {{ displaySummaryText }}
402
+ {{
403
+ typeof showCount === "function"
404
+ ? showCount(selectedValues.length)
405
+ : selectedValues.length === 1
406
+ ? getLabel(selectedValues[0])
407
+ : `Выбрано: ${selectedValues.length}`
408
+ }}
437
409
  </span>
438
410
  <div
439
411
  v-else
@@ -448,34 +420,34 @@ const radiusStyle = computed(() => {
448
420
  <span
449
421
  v-for="v in selectedValues"
450
422
  :key="v"
451
- style="
452
- display: inline-flex;
453
- align-items: center;
454
- gap: 4px;
455
- padding: 1px 6px;
456
- background: rgba(18, 103, 93, 0.1);
457
- border: 1px solid var(--accent, #12675D);
458
- border-radius: 4px;
459
- font-size: 11px;
460
- font-weight: 600;
461
- color: var(--accent, #12675D);
462
- white-space: nowrap;
463
- max-width: 140px;
464
- "
423
+ :style="{
424
+ display: 'inline-flex',
425
+ alignItems: 'center',
426
+ gap: '4px',
427
+ padding: '2px 6px',
428
+ background: 'var(--pd,var(--accent-bg, rgba(249,158,44,.1)))',
429
+ border: '1px solid var(--accent, #f99e2c)',
430
+ borderRadius: '4px',
431
+ fontSize: '11px',
432
+ fontWeight: 600,
433
+ color: 'var(--accent, #f99e2c)',
434
+ whiteSpace: 'nowrap',
435
+ maxWidth: '140px',
436
+ opacity: 0.85,
437
+ }"
465
438
  >
466
439
  <span style="overflow: hidden; text-overflow: ellipsis">
467
- {{ getOptionLabel(v) }}
440
+ {{ getLabel(v) }}
468
441
  </span>
469
442
  <X
470
443
  :size="11"
471
- style="cursor: pointer; flex-shrink: 0; opacity: 0.6"
444
+ style="cursor: pointer; flex-shrink: 0; opacity: 0.5"
472
445
  @mousedown="removeTag($event, v)"
473
446
  />
474
447
  </span>
475
448
  </div>
476
449
  </template>
477
450
 
478
- <!-- Single Select Value -->
479
451
  <span
480
452
  v-else-if="!multi && selectedValues.length > 0"
481
453
  style="
@@ -483,236 +455,215 @@ const radiusStyle = computed(() => {
483
455
  white-space: nowrap;
484
456
  overflow: hidden;
485
457
  text-overflow: ellipsis;
486
- color: var(--primary-text, #0f172a);
487
458
  "
488
459
  >
489
- {{ getOptionLabel(selectedValues[0]) }}
460
+ {{ getLabel(selectedValues[0]) || "&nbsp;" }}
490
461
  </span>
491
462
 
492
- <!-- Placeholder -->
493
463
  <span
494
464
  v-else
495
465
  style="
496
- color: var(--grey-3, #94a3b8);
466
+ color: var(--t3,var(--grey-3, #cbd5e1));
497
467
  font-weight: 400;
498
- white-space: nowrap;
499
- overflow: hidden;
500
- text-overflow: ellipsis;
501
468
  "
502
469
  >
503
470
  {{ placeholder }}
504
471
  </span>
505
472
  </div>
506
473
 
507
- <!-- Suffix / Clear / Chevron -->
508
474
  <div style="display: flex; align-items: center; gap: 4px; flex-shrink: 0">
509
- <slot name="suffix" />
475
+ <slot v-if="slots.suffix" name="suffix" />
510
476
 
511
477
  <span
512
- v-if="clearable && selectedValues.length > 0 && !disabled"
478
+ v-if="
479
+ selectedValues.length > 0 &&
480
+ selectedValues[0] !== '' &&
481
+ selectedValues[0] !== null &&
482
+ clearable
483
+ "
484
+ title="Сбросить выбор"
513
485
  style="
514
486
  display: inline-flex;
515
487
  align-items: center;
516
488
  justify-content: center;
517
489
  cursor: pointer;
518
- color: var(--muted, #94a3b8);
490
+ color: var(--muted, #64748b);
519
491
  padding: 0 2px;
520
- transition: color 0.15s;
492
+ flex-shrink: 0;
493
+ border-radius: var(--radxs, 4px);
521
494
  "
522
- title="Clear selection"
495
+ @mouseenter="($event.currentTarget as HTMLElement).style.color = 'var(--danger, #ef4444)'"
496
+ @mouseleave="($event.currentTarget as HTMLElement).style.color = 'var(--muted, #64748b)'"
523
497
  @click="handleClear"
524
498
  >
525
499
  <X :size="12" />
526
500
  </span>
527
501
 
528
502
  <ChevronDown
529
- :size="13"
503
+ v-else
504
+ :size="12"
530
505
  :style="{
531
506
  flexShrink: 0,
532
- color: 'var(--grey-3, #94a3b8)',
533
- transition: 'transform 0.2s ease',
507
+ color: 'var(--t3,var(--grey-3, #cbd5e1))',
508
+ transition: 'transform .2s ease',
534
509
  transform: open ? 'rotate(180deg)' : undefined,
535
510
  }"
536
511
  />
537
512
  </div>
538
513
  </div>
539
514
 
540
- <!-- Dropdown Menu Teleported to Body -->
515
+ <!-- Teleport dropdown to body -->
541
516
  <Teleport to="body">
542
517
  <div
543
518
  v-if="open"
544
519
  ref="dropRef"
545
- class="cynx-select-dropdown"
546
520
  :style="{
547
521
  position: 'fixed',
548
- zIndex: 9999,
522
+ zIndex: 9000,
549
523
  top: `${pos.top}px`,
550
524
  left: `${pos.left}px`,
551
525
  width: `${pos.width}px`,
552
526
  boxSizing: 'border-box',
553
- background: 'var(--sf, var(--surface, #ffffff))',
554
- border: '1px solid var(--border, #e2e8f0)',
555
- borderRadius: radiusStyle,
556
- boxShadow: 'var(--shadow, 0 4px 12px rgba(0, 0, 0, 0.08))',
557
- padding: '4px',
527
+ background: 'var(--sf,var(--surface, #ffffff))',
528
+ border: '1px solid var(--bd,var(--border, #e2e8f0))',
529
+ borderRadius: radiusVal,
530
+ boxShadow: 'var(--shadow,0 1px 3px rgba(20,22,41,.1))',
531
+ padding: '3px',
532
+ animation: `${pos.openUp ? 'selFadeInUp' : 'selFadeInDown'} .15s ease`,
558
533
  overflow: 'hidden',
559
- fontFamily: 'inherit',
560
534
  }"
561
535
  >
562
- <!-- Custom Header Slot -->
563
- <slot name="header" />
536
+ <slot v-if="slots.header" name="header" />
564
537
 
565
- <!-- Built-in Search & Select All Controls -->
538
+ <!-- Search input if searchable or creatable -->
566
539
  <div
567
- v-if="searchable || (multi && selectAll)"
568
- style="
569
- display: flex;
570
- flex-direction: column;
571
- gap: 4px;
572
- padding: 2px 2px 6px 2px;
573
- border-bottom: 1px solid var(--border, #f1f5f9);
574
- margin-bottom: 4px;
575
- "
540
+ v-if="searchable || creatable"
541
+ style="padding: 3px"
576
542
  >
577
- <!-- Search Input -->
578
- <div
579
- v-if="searchable"
580
- style="
581
- position: relative;
582
- display: flex;
583
- align-items: center;
584
- width: 100%;
585
- "
586
- >
587
- <Search
588
- :size="12"
589
- style="
590
- position: absolute;
591
- left: 8px;
592
- color: var(--grey-3, #94a3b8);
593
- pointer-events: none;
594
- "
595
- />
596
- <input
597
- ref="searchInputRef"
598
- v-model="query"
599
- type="text"
600
- placeholder="Search..."
601
- style="
602
- width: 100%;
603
- box-sizing: border-box;
604
- padding: 4px 8px 4px 26px;
605
- height: 28px;
606
- background: var(--grey-1, #f8fafc);
607
- border: 1px solid var(--border, #e2e8f0);
608
- border-radius: 6px;
609
- font-size: 12px;
610
- line-height: 1;
611
- color: var(--primary-text, #0f172a);
612
- outline: none;
613
- transition: border-color 0.15s;
614
- "
615
- @keydown.stop
616
- @mousedown.stop
617
- />
618
- </div>
543
+ <input
544
+ ref="inputRef"
545
+ v-model="query"
546
+ type="text"
547
+ :placeholder="creatable ? 'Поиск или ввод текста...' : 'Поиск...'"
548
+ :style="{
549
+ width: '100%',
550
+ boxSizing: 'border-box',
551
+ padding: s.height <= 26 ? '2px 5px' : s.height <= 28 ? '3px 6px' : '7px 10px',
552
+ height: `${s.searchHeight}px`,
553
+ background: 'var(--sf2,var(--grey-1, #f5f5f8))',
554
+ border: '1.5px solid var(--bd2,var(--border, #e2e8f0))',
555
+ borderRadius: s.height <= 26 ? '3px' : s.height <= 28 ? '4px' : 'var(--rmd,var(--rads, 8px))',
556
+ fontFamily: 'var(--f,var(--sans, inherit))',
557
+ fontSize: `${s.searchFont}px`,
558
+ lineHeight: 1,
559
+ color: 'var(--t,var(--primary-text, #0f172a))',
560
+ outline: 'none',
561
+ transition: 'border-color var(--tr,.15s), box-shadow var(--tr,.15s)',
562
+ }"
563
+ @keydown="handleInputKeyDown"
564
+ @mousedown.stop
565
+ />
566
+ </div>
619
567
 
620
- <!-- Select All Toggle -->
568
+ <div
569
+ :style="{
570
+ maxHeight: `${pos.maxHeight || 200}px`,
571
+ overflowY: 'auto',
572
+ }"
573
+ >
574
+ <!-- Creatable option row -->
621
575
  <div
622
- v-if="multi && selectAll && options.length > 0"
623
- style="
624
- display: flex;
625
- align-items: center;
626
- gap: 6px;
627
- padding: 4px 6px;
628
- border-radius: 4px;
629
- cursor: pointer;
630
- user-select: none;
631
- font-size: 12px;
632
- font-weight: 600;
633
- color: var(--primary-text, #0f172a);
576
+ v-if="
577
+ creatable &&
578
+ query.trim() &&
579
+ !options.some(
580
+ (o) => String(o.label || o.value || '').toLowerCase() === query.trim().toLowerCase()
581
+ )
582
+ "
583
+ :style="{
584
+ display: 'flex',
585
+ alignItems: 'center',
586
+ gap: '6px',
587
+ minHeight: `${s.itemMinHeight}px`,
588
+ padding: s.itemPadding,
589
+ borderRadius: s.height <= 26 ? '3px' : s.height <= 28 ? '4px' : 'var(--rmd,var(--rads, 8px))',
590
+ cursor: 'pointer',
591
+ color: 'var(--accent, #f99e2c)',
592
+ background: 'var(--pd,var(--accent-bg, rgba(249,158,44,.08)))',
593
+ fontSize: `${s.searchFont}px`,
594
+ fontWeight: 600,
595
+ marginBottom: filtered.length > 0 ? '3px' : '0px',
596
+ userSelect: 'none',
597
+ }"
598
+ @mousedown.prevent.stop="
599
+ () => {
600
+ toggleOption(query.trim());
601
+ query = '';
602
+ }
634
603
  "
635
- @mousedown.prevent.stop="toggleSelectAll"
636
604
  >
605
+ <Plus :size="12" style="flex-shrink: 0" />
637
606
  <span
638
607
  style="
639
- width: 14px;
640
- height: 14px;
641
- border-radius: 3px;
642
- display: flex;
643
- align-items: center;
644
- justify-content: center;
645
- transition: all 0.15s;
608
+ flex: 1;
609
+ white-space: nowrap;
610
+ overflow: hidden;
611
+ text-overflow: ellipsis;
646
612
  "
647
- :style="{
648
- border: `1.5px solid ${isAllSelected ? 'var(--accent, #12675D)' : 'var(--border, #cbd5e1)'}`,
649
- background: isAllSelected ? 'var(--accent, #12675D)' : 'transparent',
650
- }"
651
613
  >
652
- <Check v-if="isAllSelected" :size="10" color="#ffffff" />
614
+ {{
615
+ typeof createLabel === "function"
616
+ ? createLabel(query.trim())
617
+ : `Ввести: "${query.trim()}"`
618
+ }}
653
619
  </span>
654
- <span>{{ selectAllText }}</span>
655
620
  </div>
656
- </div>
657
621
 
658
- <!-- Options List -->
659
- <div
660
- :style="{
661
- maxHeight: `${pos.maxHeight}px`,
662
- overflowY: 'auto',
663
- }"
664
- >
622
+ <!-- Empty state -->
665
623
  <div
666
- v-if="filteredOptions.length === 0"
624
+ v-if="filtered.length === 0 && (!creatable || !query.trim())"
667
625
  style="
668
626
  padding: 10px 8px;
669
627
  text-align: center;
670
- color: var(--grey-3, #94a3b8);
671
- font-size: 12px;
628
+ color: var(--t3,var(--grey-3, #cbd5e1));
629
+ font-size: 11px;
672
630
  "
673
631
  >
674
- No items found
632
+ Ничего не найдено
675
633
  </div>
676
634
 
635
+ <!-- Option items -->
677
636
  <div
678
- v-for="opt in filteredOptions"
637
+ v-for="opt in filtered"
679
638
  :key="opt.value"
680
639
  :style="{
681
640
  display: 'flex',
682
641
  alignItems: 'center',
683
- justifyContent: 'space-between',
684
642
  gap: '6px',
685
643
  minHeight: `${s.itemMinHeight}px`,
686
644
  padding: s.itemPadding,
687
- borderRadius: '4px',
645
+ borderRadius: s.height <= 26 ? '3px' : s.height <= 28 ? '4px' : 'var(--rmd,var(--rads, 8px))',
688
646
  cursor: opt.disabled ? 'not-allowed' : 'pointer',
689
- color: isOptionSelected(opt.value)
690
- ? 'var(--accent, #12675D)'
691
- : 'var(--primary-text, #0f172a)',
692
- background: isOptionSelected(opt.value)
693
- ? 'rgba(18, 103, 93, 0.08)'
694
- : 'transparent',
647
+ color: isSelected(opt.value)
648
+ ? 'var(--accent, #f99e2c)'
649
+ : 'var(--t,var(--primary-text, #0f172a))',
695
650
  fontSize: `${s.searchFont}px`,
696
- fontWeight: isOptionSelected(opt.value) ? 500 : 400,
697
- transition: 'background 0.1s',
651
+ fontWeight: 400,
652
+ transition: 'background .1s',
698
653
  userSelect: 'none',
699
654
  opacity: opt.disabled ? 0.4 : 1,
700
655
  pointerEvents: opt.disabled ? 'none' : undefined,
701
656
  }"
702
657
  @mouseenter="
703
658
  (e) => {
704
- if (!opt.disabled && !isOptionSelected(opt.value)) {
705
- (e.currentTarget as HTMLElement).style.background =
706
- 'var(--grey-1, #f8fafc)';
707
- }
659
+ if (!opt.disabled)
660
+ (e.currentTarget as HTMLElement).style.background = 'var(--grey-1, #f5f5f8)';
708
661
  }
709
662
  "
710
663
  @mouseleave="
711
664
  (e) => {
712
- if (!opt.disabled && !isOptionSelected(opt.value)) {
713
- (e.currentTarget as HTMLElement).style.background =
714
- 'transparent';
715
- }
665
+ if (!opt.disabled)
666
+ (e.currentTarget as HTMLElement).style.background = 'transparent';
716
667
  }
717
668
  "
718
669
  @mousedown.prevent.stop="
@@ -721,34 +672,34 @@ const radiusStyle = computed(() => {
721
672
  }
722
673
  "
723
674
  >
724
- <!-- Option Content Slot or Default -->
725
675
  <slot
676
+ v-if="slots.option"
726
677
  name="option"
727
678
  :option="opt"
728
- :selected="isOptionSelected(opt.value)"
679
+ :selected="isSelected(opt.value)"
680
+ />
681
+ <span
682
+ v-else
683
+ style="
684
+ flex: 1;
685
+ white-space: nowrap;
686
+ overflow: hidden;
687
+ text-overflow: ellipsis;
688
+ min-height: 1em;
689
+ "
729
690
  >
730
- <span
731
- style="
732
- flex: 1;
733
- white-space: nowrap;
734
- overflow: hidden;
735
- text-overflow: ellipsis;
736
- "
737
- >
738
- {{ opt.label }}
739
- </span>
740
- </slot>
691
+ {{ opt.displayLabel || opt.label || "&nbsp;" }}
692
+ </span>
741
693
 
742
694
  <Check
743
- v-if="isOptionSelected(opt.value)"
695
+ v-if="isSelected(opt.value)"
744
696
  :size="13"
745
- style="flex-shrink: 0; color: var(--accent, #12675D)"
697
+ style="flex-shrink: 0; color: var(--accent, #f99e2c)"
746
698
  />
747
699
  </div>
748
700
  </div>
749
701
 
750
- <!-- Custom Footer Slot -->
751
- <slot name="footer" />
702
+ <slot v-if="slots.footer" name="footer" />
752
703
  </div>
753
704
  </Teleport>
754
705
  </div>