dxd-style-code 0.1.13 → 0.1.15

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.
@@ -1,52 +1,93 @@
1
1
  <template>
2
- <div class="inline-flex" data-component="DXSegmentedControl">
2
+ <div
3
+ class="flex-col"
4
+ :class="scrollable ? 'flex' : 'inline-flex'"
5
+ :style="scrollable && maxWidth ? { maxWidth, width: '100%' } : {}"
6
+ data-component="DXSegmentedControl"
7
+ :data-scrollable="scrollable"
8
+ >
3
9
  <p v-if="label" class="text-sm font-medium text-slate-700 mb-2">{{ label }}</p>
4
- <div class="relative inline-flex p-1 bg-slate-100 rounded-xl gap-1">
5
- <!-- Floating indicator -->
6
- <div
7
- class="absolute bg-white rounded-lg shadow-sm transition-all duration-200 ease-out"
8
- :style="indicatorStyle"
10
+
11
+ <!-- Wrapper для градиентов -->
12
+ <div
13
+ class="relative w-full"
14
+ :class="scrollable && 'scroll-wrapper'"
15
+ >
16
+ <!-- Левый градиент -->
17
+ <div
18
+ v-if="scrollable && showFade"
19
+ class="fade-left"
20
+ :class="canScrollLeft ? 'opacity-100' : 'opacity-0'"
9
21
  />
10
22
 
11
- <!-- Buttons -->
12
- <button
13
- v-for="(option, index) in options"
14
- :key="option.value"
15
- type="button"
16
- :ref="el => { if (el) buttonRefs[index] = el }"
17
- class="relative z-10 px-4 py-1.5 text-sm font-medium transition-colors duration-150 rounded-lg whitespace-nowrap min-w-[60px] text-center inline-flex items-center justify-center gap-1.5"
23
+ <!-- Правый градиент -->
24
+ <div
25
+ v-if="scrollable && showFade"
26
+ class="fade-right"
27
+ :class="canScrollRight ? 'opacity-100' : 'opacity-0'"
28
+ />
29
+
30
+ <!-- Scrollable container -->
31
+ <div
32
+ ref="containerRef"
33
+ class="relative p-1 bg-slate-100 rounded-xl gap-1"
18
34
  :class="[
19
- modelValue === option.value
20
- ? 'text-slate-900'
21
- : 'text-slate-600 hover:text-slate-900',
22
- disabled && 'opacity-60 cursor-not-allowed',
35
+ scrollable ? 'flex overflow-x-auto scrollbar-hide w-full' : 'inline-flex',
36
+ isDragging && 'cursor-grabbing select-none',
37
+ scrollable && !isDragging && 'cursor-grab'
23
38
  ]"
24
- :disabled="disabled"
25
- @click="select(option.value)"
39
+ @mousedown="handleMouseDown"
40
+ @mousemove="handleMouseMove"
41
+ @mouseup="handleMouseUp"
42
+ @mouseleave="handleMouseUp"
43
+ @scroll="handleScroll"
26
44
  >
27
- <DXIcon
28
- v-if="option.icon"
29
- :icon="option.icon"
30
- size="xs"
31
- :animation="getIconAnimation(option)"
45
+ <!-- Floating indicator -->
46
+ <div
47
+ class="absolute bg-white rounded-lg shadow-sm transition-all duration-200 ease-out pointer-events-none"
48
+ :style="indicatorStyle"
32
49
  />
33
- <span v-if="option.label">{{ option.label }}</span>
34
- <span
35
- v-if="option.count !== undefined && option.count !== null"
36
- class="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-[11px] font-semibold rounded-full"
37
- :class="modelValue === option.value
38
- ? 'bg-slate-800 text-white'
39
- : 'bg-slate-200 text-slate-700'"
50
+
51
+ <!-- Buttons -->
52
+ <button
53
+ v-for="(option, index) in options"
54
+ :key="option.value"
55
+ type="button"
56
+ :ref="el => { if (el) buttonRefs[index] = el }"
57
+ class="relative z-10 px-4 py-1.5 text-sm font-medium transition-colors duration-150 rounded-lg whitespace-nowrap min-w-[60px] text-center inline-flex items-center justify-center gap-1.5 flex-shrink-0"
58
+ :class="[
59
+ modelValue === option.value
60
+ ? 'text-slate-900'
61
+ : 'text-slate-600 hover:text-slate-900',
62
+ disabled && 'opacity-60 cursor-not-allowed',
63
+ ]"
64
+ :disabled="disabled"
65
+ @click="handleButtonClick(option.value, $event)"
40
66
  >
41
- {{ option.count }}
42
- </span>
43
- </button>
67
+ <DXIcon
68
+ v-if="option.icon"
69
+ :icon="option.icon"
70
+ size="xs"
71
+ :animation="getIconAnimation(option)"
72
+ />
73
+ <span v-if="option.label">{{ option.label }}</span>
74
+ <span
75
+ v-if="option.count !== undefined && option.count !== null"
76
+ class="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-[11px] font-semibold rounded-full"
77
+ :class="modelValue === option.value
78
+ ? 'bg-slate-800 text-white'
79
+ : 'bg-slate-200 text-slate-700'"
80
+ >
81
+ {{ option.count }}
82
+ </span>
83
+ </button>
84
+ </div>
44
85
  </div>
45
86
  </div>
46
87
  </template>
47
88
 
48
89
  <script setup>
49
- import { ref, computed, watch, nextTick, onMounted } from "vue";
90
+ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from "vue";
50
91
  import DXIcon from "../../atoms/DXIcon/DXIcon.vue";
51
92
 
52
93
  const props = defineProps({
@@ -61,13 +102,40 @@ const props = defineProps({
61
102
  /** Анимировать только активную иконку */
62
103
  animateActiveOnly: { type: Boolean, default: true },
63
104
  disabled: { type: Boolean, default: false },
105
+ /**
106
+ * Включить режим прокрутки (без скроллбара, с поддержкой drag-to-scroll)
107
+ * @default false
108
+ */
109
+ scrollable: { type: Boolean, default: false },
110
+ /**
111
+ * Максимальная ширина контейнера (при scrollable: true)
112
+ * Может быть строкой CSS (например '300px', '100%', '50vw')
113
+ * @default null
114
+ */
115
+ maxWidth: { type: String, default: null },
116
+ /**
117
+ * Показывать градиенты размытия по краям при scrollable
118
+ * @default true
119
+ */
120
+ showFade: { type: Boolean, default: true },
64
121
  });
65
122
 
66
123
  const emit = defineEmits(["update:modelValue"]);
67
124
 
125
+ const containerRef = ref(null);
68
126
  const buttonRefs = ref([]);
69
127
  const indicatorStyle = ref({});
70
128
 
129
+ // Drag-to-scroll state
130
+ const isDragging = ref(false);
131
+ const startX = ref(0);
132
+ const scrollLeftPos = ref(0);
133
+ const hasDragged = ref(false);
134
+
135
+ // Scroll position state для градиентов
136
+ const canScrollLeft = ref(false);
137
+ const canScrollRight = ref(false);
138
+
71
139
  const selectedIndex = computed(() => {
72
140
  return props.options.findIndex((opt) => opt.value === props.modelValue);
73
141
  });
@@ -76,10 +144,13 @@ const updateIndicator = () => {
76
144
  const index = selectedIndex.value;
77
145
  if (index >= 0 && buttonRefs.value[index]) {
78
146
  const button = buttonRefs.value[index];
147
+ // В scrollable режиме корректируем позицию на величину скролла,
148
+ // т.к. absolute элемент не скроллируется вместе с контентом
149
+ const scrollOffset = props.scrollable && containerRef.value ? containerRef.value.scrollLeft : 0;
79
150
  indicatorStyle.value = {
80
151
  width: `${button.offsetWidth}px`,
81
152
  height: `${button.offsetHeight}px`,
82
- left: `${button.offsetLeft}px`,
153
+ left: `${button.offsetLeft - scrollOffset}px`,
83
154
  top: `${button.offsetTop}px`,
84
155
  };
85
156
  }
@@ -105,12 +176,156 @@ const getIconAnimation = (option) => {
105
176
  return props.iconAnimation;
106
177
  };
107
178
 
179
+ // Обновление состояния градиентов
180
+ const updateScrollState = () => {
181
+ if (!containerRef.value) return;
182
+
183
+ const { scrollLeft, scrollWidth, clientWidth } = containerRef.value;
184
+ canScrollLeft.value = scrollLeft > 2; // Небольшой threshold
185
+ canScrollRight.value = scrollLeft < scrollWidth - clientWidth - 2;
186
+ };
187
+
188
+ // Обработчик scroll события
189
+ const handleScroll = () => {
190
+ updateScrollState();
191
+ updateIndicator(); // Обновляем позицию индикатора при скролле
192
+ };
193
+
194
+ // Drag-to-scroll handlers
195
+ const handleMouseDown = (e) => {
196
+ if (!props.scrollable || !containerRef.value) return;
197
+
198
+ isDragging.value = true;
199
+ hasDragged.value = false;
200
+ startX.value = e.pageX - containerRef.value.offsetLeft;
201
+ scrollLeftPos.value = containerRef.value.scrollLeft;
202
+ };
203
+
204
+ const handleMouseMove = (e) => {
205
+ if (!isDragging.value || !containerRef.value) return;
206
+
207
+ e.preventDefault();
208
+ const x = e.pageX - containerRef.value.offsetLeft;
209
+ const walk = (x - startX.value) * 1.5; // Множитель скорости прокрутки
210
+
211
+ // Если переместились более чем на 5px, считаем это drag
212
+ if (Math.abs(walk) > 5) {
213
+ hasDragged.value = true;
214
+ }
215
+
216
+ containerRef.value.scrollLeft = scrollLeftPos.value - walk;
217
+ };
218
+
219
+ const handleMouseUp = () => {
220
+ isDragging.value = false;
221
+ // Сбрасываем hasDragged с небольшой задержкой, чтобы click event успел проверить флаг
222
+ setTimeout(() => {
223
+ hasDragged.value = false;
224
+ }, 0);
225
+ };
226
+
227
+ // Обработчик клика по кнопке с учетом drag
228
+ const handleButtonClick = (value, event) => {
229
+ // Если был drag, не выбираем элемент
230
+ if (hasDragged.value) {
231
+ event.preventDefault();
232
+ return;
233
+ }
234
+ select(value);
235
+ };
236
+
237
+ // Прокрутка к выбранному элементу
238
+ const scrollToSelected = () => {
239
+ if (!props.scrollable || !containerRef.value) return;
240
+
241
+ const index = selectedIndex.value;
242
+ if (index >= 0 && buttonRefs.value[index]) {
243
+ const button = buttonRefs.value[index];
244
+ const container = containerRef.value;
245
+
246
+ // Центрируем выбранный элемент
247
+ const scrollPosition = button.offsetLeft - (container.clientWidth / 2) + (button.offsetWidth / 2);
248
+ container.scrollTo({
249
+ left: scrollPosition,
250
+ behavior: 'smooth'
251
+ });
252
+ }
253
+ };
254
+
108
255
  watch(() => props.modelValue, () => {
109
- nextTick(updateIndicator);
256
+ nextTick(() => {
257
+ updateIndicator();
258
+ scrollToSelected();
259
+ });
110
260
  });
111
261
 
262
+ // Отслеживание изменения размера для обновления градиентов (ref для изоляции между экземплярами)
263
+ const resizeObserver = ref(null);
264
+
112
265
  onMounted(() => {
113
- nextTick(updateIndicator);
266
+ nextTick(() => {
267
+ updateIndicator();
268
+ updateScrollState();
269
+ scrollToSelected(); // Центрируем выбранный элемент при монтировании
270
+
271
+ // ResizeObserver для отслеживания изменения размера контейнера
272
+ if (props.scrollable && containerRef.value && window.ResizeObserver) {
273
+ resizeObserver.value = new ResizeObserver(() => {
274
+ updateScrollState();
275
+ });
276
+ resizeObserver.value.observe(containerRef.value);
277
+ }
278
+ });
279
+ });
280
+
281
+ onBeforeUnmount(() => {
282
+ if (resizeObserver.value) {
283
+ resizeObserver.value.disconnect();
284
+ resizeObserver.value = null;
285
+ }
114
286
  });
115
287
  </script>
116
288
 
289
+ <style scoped>
290
+ /* Скрытие скроллбара для режима прокрутки */
291
+ .scrollbar-hide {
292
+ -ms-overflow-style: none; /* IE и Edge */
293
+ scrollbar-width: none; /* Firefox */
294
+ }
295
+
296
+ .scrollbar-hide::-webkit-scrollbar {
297
+ display: none; /* Chrome, Safari, Opera */
298
+ }
299
+
300
+ /* Wrapper для градиентов */
301
+ .scroll-wrapper {
302
+ position: relative;
303
+ }
304
+
305
+ /* Градиенты по краям */
306
+ .fade-left,
307
+ .fade-right {
308
+ position: absolute;
309
+ top: 0;
310
+ bottom: 0;
311
+ width: 24px;
312
+ pointer-events: none;
313
+ z-index: 20;
314
+ transition: opacity 150ms ease;
315
+ border-radius: 0.75rem; /* rounded-xl */
316
+ }
317
+
318
+ .fade-left {
319
+ left: 0;
320
+ background: linear-gradient(to right, rgb(241 245 249 / 1) 0%, rgb(241 245 249 / 0) 100%);
321
+ border-top-right-radius: 0;
322
+ border-bottom-right-radius: 0;
323
+ }
324
+
325
+ .fade-right {
326
+ right: 0;
327
+ background: linear-gradient(to left, rgb(241 245 249 / 1) 0%, rgb(241 245 249 / 0) 100%);
328
+ border-top-left-radius: 0;
329
+ border-bottom-left-radius: 0;
330
+ }
331
+ </style>
@@ -8,3 +8,5 @@ export { default } from './DXTableFiltersPanel.vue';
8
8
 
9
9
 
10
10
 
11
+
12
+