cynx-lib 1.3.84 → 1.3.85

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