cynx-lib 1.3.80 → 1.3.82

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 (41) hide show
  1. package/gen.js +77 -77
  2. package/package.json +1 -1
  3. package/ui/components/Drawer.jsx +208 -208
  4. package/ui/components/Layout.jsx +138 -138
  5. package/ui/components/LoginPage.jsx +405 -405
  6. package/ui/components/Pagination.jsx +145 -145
  7. package/ui/components/PrivacyBlur.jsx +603 -603
  8. package/ui/components/Sidebar.jsx +820 -820
  9. package/utils/index.js +20 -20
  10. package/utils/utils/duration.js +139 -139
  11. package/utils/utils/oidc.js +214 -214
  12. package/utils/utils/realtime.js +1634 -1634
  13. package/utils/utils/session.js +58 -58
  14. package/utils/utils/storage.js +393 -393
  15. package/utils/utils/storage_manager.cjs +84 -7
  16. package/utils/utils/storage_manager.js +95 -8
  17. package/utils/utils/ui.js +111 -111
  18. package/vue/components/Badge.vue +79 -79
  19. package/vue/components/Button.vue +209 -209
  20. package/vue/components/Checkbox.vue +103 -103
  21. package/vue/components/CircularProgress.vue +383 -383
  22. package/vue/components/Divider.vue +94 -94
  23. package/vue/components/Drawer.vue +243 -243
  24. package/vue/components/InfoBadge.vue +223 -223
  25. package/vue/components/Input.vue +341 -341
  26. package/vue/components/LinearProgress.vue +268 -268
  27. package/vue/components/Modal.vue +226 -226
  28. package/vue/components/Pagination.vue +199 -199
  29. package/vue/components/Select.vue +713 -713
  30. package/vue/components/Sidebar.vue +955 -955
  31. package/vue/components/Skeleton.vue +91 -91
  32. package/vue/components/Spinner.vue +263 -263
  33. package/vue/components/StatusTag.vue +89 -89
  34. package/vue/components/TabBar.vue +176 -176
  35. package/vue/components/Table.vue +388 -388
  36. package/vue/components/TimePicker.vue +399 -399
  37. package/vue/components/Toggle.vue +115 -115
  38. package/vue/components/XTable.vue +119 -119
  39. package/vue/composables/useAutoSkeletonRows.ts +71 -71
  40. package/vue/index.d.ts +67 -67
  41. 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>