@formixru/b2b-ui 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@formixru/b2b-ui",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,74 @@
1
+ @use '../../variables' as *;
2
+
3
+ $b: '.search-input';
4
+
5
+ #{$b} {
6
+ position: relative;
7
+
8
+ &__submit {
9
+ align-items: center;
10
+ display: flex;
11
+ height: 24px;
12
+ justify-content: center;
13
+ left: 12px;
14
+ position: absolute;
15
+ top: calc(50% - 12px);
16
+ width: 24px;
17
+ }
18
+
19
+ &__submit-icon {
20
+ fill: $search-input-submit-icon-color;
21
+ height: 16px;
22
+ width: 16px;
23
+
24
+ #{$b}__input:not(:placeholder-shown) ~ #{$b}__submit & {
25
+ fill: $search-input-submit-icon-active-color;
26
+ }
27
+ }
28
+
29
+ &__spinner {
30
+ container-type: size;
31
+ height: 16px;
32
+ width: 16px;
33
+ }
34
+
35
+ &__input {
36
+ padding-left: 44px;
37
+ width: 100%;
38
+ }
39
+
40
+ &__clear {
41
+ align-items: center;
42
+ background-color: $search-input-clear-bg;
43
+ border-bottom-right-radius: $search-input-clear-radius;
44
+ border-top-right-radius: $search-input-clear-radius;
45
+ color: $search-input-clear-color;
46
+ cursor: pointer;
47
+ display: flex;
48
+ height: $input-height - $input-border-width * 2;
49
+ justify-content: center;
50
+ position: absolute;
51
+ right: 1px;
52
+ top: 1px;
53
+ transition-duration: var(--duration-micro-s);
54
+ transition-property: color;
55
+ transition-timing-function: var(--easing-standard);
56
+ width: $input-height - $input-border-width * 2;
57
+
58
+ .input_small ~ & {
59
+ height: $input-small-height - $input-border-width * 2;
60
+ width: $input-small-height - $input-border-width * 2;
61
+ }
62
+
63
+ &:hover,
64
+ &:focus-visible {
65
+ color: $search-input-clear-active-color;
66
+ }
67
+ }
68
+
69
+ &__clear-icon {
70
+ fill: currentcolor;
71
+ height: 16px;
72
+ width: 16px;
73
+ }
74
+ }
@@ -0,0 +1,113 @@
1
+ <template>
2
+ <div ref="$root" class="search-input">
3
+ <input
4
+ ref="$input"
5
+ v-model="query"
6
+ class="search-input__input input"
7
+ :class="props.inputCssClasses"
8
+ :name="props.name"
9
+ type="text"
10
+ :placeholder="props.placeholder"
11
+ autocomplete="off"
12
+ @focusin="emit('focusin', $event)"
13
+ @focusout="emit('focusout', $event)"
14
+ @keydown="emit('keydown', $event)"
15
+ />
16
+ <button
17
+ class="search-input__submit"
18
+ type="button"
19
+ @click="
20
+ updateModel();
21
+ emit('submit');
22
+ "
23
+ >
24
+ <span v-show="props.isLoading" class="search-input__spinner">
25
+ <span class="spinner"></span>
26
+ </span>
27
+ <svg v-show="!props.isLoading" class="search-input__submit-icon">
28
+ <use :href="`${ICONS_PATH}#search`" />
29
+ </svg>
30
+ </button>
31
+ <button v-show="query.length > 0" class="search-input__clear" type="button" @click="query = ''">
32
+ <svg class="search-input__clear-icon"><use :href="`${ICONS_PATH}#close`" /></svg>
33
+ </button>
34
+ </div>
35
+ </template>
36
+
37
+ <script setup>
38
+ import { cssVar } from '@advdominion/css-var';
39
+ import { debounce } from 'es-toolkit';
40
+ import { ref, useTemplateRef, watch, watchEffect } from 'vue';
41
+
42
+ const ICONS_PATH = window.ICONS_PATH;
43
+
44
+ const emit = defineEmits(['focusin', 'focusout', 'keydown', 'submit']);
45
+ const model = defineModel({ type: String, default: '' });
46
+
47
+ const props = defineProps({
48
+ name: {
49
+ type: String,
50
+ default: '',
51
+ },
52
+ placeholder: {
53
+ type: String,
54
+ default: 'Поиск',
55
+ },
56
+ minlength: {
57
+ type: Number,
58
+ default: 3,
59
+ },
60
+ inputDelay: {
61
+ type: Number,
62
+ default() {
63
+ return cssVar('--input-delay');
64
+ },
65
+ },
66
+ inputCssClasses: {
67
+ type: Array,
68
+ default() {
69
+ return [];
70
+ },
71
+ },
72
+ isLoading: {
73
+ type: Boolean,
74
+ },
75
+ });
76
+
77
+ const $root = useTemplateRef('$root');
78
+ const $input = useTemplateRef('$input');
79
+
80
+ const query = ref(model.value);
81
+ watchEffect(() => {
82
+ query.value = model.value;
83
+ });
84
+
85
+ const debouncedUpdateModel = debounce(() => {
86
+ model.value = query.value;
87
+ }, props.inputDelay);
88
+
89
+ const updateModel = () => {
90
+ debouncedUpdateModel.cancel();
91
+
92
+ if (query.value.length === 0) {
93
+ model.value = query.value;
94
+ } else if (query.value.length >= props.minlength) {
95
+ debouncedUpdateModel();
96
+ }
97
+ };
98
+
99
+ watch(query, (newQueryValue) => {
100
+ if (newQueryValue !== model.value) {
101
+ updateModel();
102
+ }
103
+ });
104
+
105
+ const focus = () => {
106
+ $input.value.focus();
107
+ };
108
+
109
+ defineExpose({
110
+ $root,
111
+ focus,
112
+ });
113
+ </script>
@@ -109,9 +109,6 @@ $b: '.suggest-input';
109
109
  display: flex;
110
110
  min-height: $suggest-input-item-height;
111
111
  padding: 4px $input-padding-x;
112
- transition-duration: var(--duration-micro-s);
113
- transition-property: background-color, color;
114
- transition-timing-function: var(--easing-standard);
115
112
 
116
113
  @include font($suggest-input-item-font);
117
114
 
@@ -121,6 +118,11 @@ $b: '.suggest-input';
121
118
  color: $suggest-input-item-active-color;
122
119
  }
123
120
 
121
+ &_disabled {
122
+ color: $suggest-input-item-disabled-color;
123
+ pointer-events: none;
124
+ }
125
+
124
126
  mark {
125
127
  background-color: transparent;
126
128
  color: $suggest-input-item-mark-color;
@@ -1,16 +1,26 @@
1
1
  <template>
2
2
  <div ref="$root" class="suggest-input" @keydown.esc="esc()">
3
+ <SearchInput
4
+ v-if="props.searchInput"
5
+ v-model="model"
6
+ class="suggest-input__input"
7
+ :class="[showSuggests ? 'suggest-input__input_active' : '']"
8
+ :placeholder="props.placeholder"
9
+ :minlength="0"
10
+ :input-delay="0"
11
+ :input-css-classes="props.inputCssClasses"
12
+ :is-loading="isLoading"
13
+ @update:model-value="handleInput()"
14
+ @focusin="focusin()"
15
+ @focusout="focusout($event)"
16
+ @keydown="arrowNavigation($event)"
17
+ />
3
18
  <input
19
+ v-else
4
20
  :id="props.id"
5
- ref="$input"
6
21
  v-model="model"
7
22
  class="suggest-input__input input"
8
- :class="[
9
- showSuggests ? 'suggest-input__input_active' : '',
10
- props.small ? 'input_small' : '',
11
- props.error ? 'input_error' : '',
12
- ...props.inputCssClasses,
13
- ]"
23
+ :class="[showSuggests ? 'suggest-input__input_active' : '', ...props.inputCssClasses]"
14
24
  type="text"
15
25
  :name="props.name"
16
26
  :required="props.required"
@@ -22,8 +32,14 @@
22
32
  @focusout="focusout($event)"
23
33
  @keydown="arrowNavigation($event)"
24
34
  />
25
- <div v-show="isLoading" class="suggest-input__loader"><div class="spinner"></div></div>
26
- <button v-show="isClearVisible" ref="$clear" class="suggest-input__clear" type="button" @click="clear()">
35
+ <div v-show="isLoading && !props.searchInput" class="suggest-input__loader"><div class="spinner"></div></div>
36
+ <button
37
+ v-show="isClearVisible && !props.searchInput"
38
+ ref="$clear"
39
+ class="suggest-input__clear"
40
+ type="button"
41
+ @click="clear()"
42
+ >
27
43
  <svg class="suggest-input__clear-icon"><use :href="`${ICONS_PATH}#close`" /></svg>
28
44
  </button>
29
45
  <div v-show="showSuggests" ref="$list" class="suggest-input__list" @mouseover="focusSuggestIndex = -1">
@@ -32,7 +48,10 @@
32
48
  v-for="(item, index) in formattedSuggests"
33
49
  :key="index"
34
50
  class="suggest-input__list-item"
35
- :class="{ 'suggest-input__list-item_focus': index === focusSuggestIndex }"
51
+ :class="{
52
+ 'suggest-input__list-item_focus': index === focusSuggestIndex,
53
+ 'suggest-input__list-item_disabled': item.disabled,
54
+ }"
36
55
  tabindex="0"
37
56
  @click="select(item)"
38
57
  >
@@ -52,6 +71,7 @@ import { debounce } from 'es-toolkit';
52
71
  import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
53
72
 
54
73
  import { request } from '../request/index.js';
74
+ import SearchInput from '../search-input/index.vue';
55
75
 
56
76
  const ICONS_PATH = window.ICONS_PATH;
57
77
 
@@ -109,12 +129,6 @@ const props = defineProps({
109
129
  type: String,
110
130
  default: 'По&nbsp;вашему запросу ничего не&nbsp;найдено',
111
131
  },
112
- small: {
113
- type: Boolean,
114
- },
115
- error: {
116
- type: Boolean,
117
- },
118
132
  loaderDelay: {
119
133
  type: Number,
120
134
  default() {
@@ -133,10 +147,12 @@ const props = defineProps({
133
147
  return cssVar('--fallback-timeout');
134
148
  },
135
149
  },
150
+ searchInput: {
151
+ type: Boolean,
152
+ },
136
153
  });
137
154
 
138
155
  const $root = useTemplateRef('$root');
139
- const $input = useTemplateRef('$input');
140
156
  const $list = useTemplateRef('$list');
141
157
  const $clear = useTemplateRef('$clear');
142
158
 
@@ -157,43 +173,13 @@ watch(suggests, () => {
157
173
  });
158
174
 
159
175
  const showSuggests = ref(false);
160
- const itemHeight = ref(0);
161
176
 
162
177
  watch(showSuggests, async (value) => {
163
- if (value && suggests.value.length > 0) {
164
- await nextTick();
165
- itemHeight.value = $list.value.querySelector('.suggest-input__list-item').offsetHeight;
166
- } else {
178
+ if (!value) {
167
179
  suggests.value = [];
168
- await nextTick();
169
- itemHeight.value = 0;
170
180
  }
171
181
  });
172
182
 
173
- const maxItemsWithoutScroll = computed(() => {
174
- if (itemHeight.value) {
175
- return Math.floor($list.value.offsetHeight / itemHeight.value);
176
- }
177
- return 0;
178
- });
179
-
180
- const indexesToScroll = computed(() => {
181
- if (maxItemsWithoutScroll.value) {
182
- const indexes = [0];
183
- for (const index of suggests.value.keys()) {
184
- const nextIndex = indexes.at(-1) + maxItemsWithoutScroll.value;
185
- if (nextIndex > suggests.value.length) {
186
- break;
187
- }
188
- if (index === nextIndex) {
189
- indexes.push(index);
190
- }
191
- }
192
- return indexes;
193
- }
194
- return [];
195
- });
196
-
197
183
  const isLoading = ref(false);
198
184
  const isClearVisible = computed(() => model.value.length >= props.minlength && !isLoading.value);
199
185
 
@@ -306,60 +292,88 @@ const clear = () => {
306
292
  showSuggests.value = false;
307
293
  };
308
294
 
309
- const select = ({ label, value }) => {
295
+ const select = (item) => {
296
+ if (item.disabled) {
297
+ return;
298
+ }
299
+
310
300
  cancelRequest();
311
- model.value = label;
301
+ model.value = item.label;
312
302
  showSuggests.value = false;
313
303
  emit('select', {
314
304
  input: model.value,
315
- value,
305
+ value: item.value,
316
306
  });
317
307
  };
318
308
 
319
- const arrowNavigation = (event) => {
309
+ const arrowNavigation = async (event) => {
320
310
  if (showSuggests.value) {
321
311
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
322
- const selectionStart = $input.value.selectionStart;
323
- const selectionEnd = $input.value.selectionEnd;
324
- setTimeout(() => {
325
- $input.value.setSelectionRange(selectionStart, selectionEnd);
326
- });
312
+ event.preventDefault();
313
+
314
+ const len = suggests.value.length;
315
+ if (len === 0) {
316
+ return;
317
+ }
318
+
327
319
  switch (event.key) {
328
320
  case 'ArrowDown': {
329
- focusSuggestIndex.value =
330
- suggests.value.length === focusSuggestIndex.value + 1 ? 0 : focusSuggestIndex.value + 1;
331
- if (
332
- focusSuggestIndex.value !== 0 &&
333
- focusSuggestIndex.value !== suggests.value.length - 1 &&
334
- indexesToScroll.value.includes(focusSuggestIndex.value)
335
- ) {
336
- $list.value.scrollTop += maxItemsWithoutScroll.value * itemHeight.value;
337
- } else if (focusSuggestIndex.value === 0) {
338
- $list.value.scrollTop = 0;
321
+ let nextIndex = focusSuggestIndex.value;
322
+ for (let i = 0; i < len; i++) {
323
+ nextIndex = (nextIndex + 1) % len;
324
+ if (!suggests.value[nextIndex]?.disabled) {
325
+ focusSuggestIndex.value = nextIndex;
326
+ break;
327
+ }
339
328
  }
340
329
  break;
341
330
  }
342
331
  case 'ArrowUp': {
343
- focusSuggestIndex.value =
344
- focusSuggestIndex.value <= 0 ? suggests.value.length - 1 : focusSuggestIndex.value - 1;
345
- if (
346
- focusSuggestIndex.value !== 0 &&
347
- focusSuggestIndex.value !== suggests.value.length - 1 &&
348
- indexesToScroll.value.includes(focusSuggestIndex.value + 1)
349
- ) {
350
- $list.value.scrollTop -= maxItemsWithoutScroll.value * itemHeight.value;
351
- } else if (focusSuggestIndex.value === suggests.value.length - 1) {
352
- $list.value.scrollTop = suggests.value.length * itemHeight.value;
332
+ let nextIndex = focusSuggestIndex.value === -1 ? 0 : focusSuggestIndex.value;
333
+ for (let i = 0; i < len; i++) {
334
+ nextIndex = (nextIndex - 1 + len) % len;
335
+ if (!suggests.value[nextIndex]?.disabled) {
336
+ focusSuggestIndex.value = nextIndex;
337
+ break;
338
+ }
353
339
  }
354
340
  break;
355
341
  }
356
342
  }
343
+
344
+ await nextTick();
345
+
346
+ const activeElement = $list.value?.querySelectorAll('.suggest-input__list-item')[focusSuggestIndex.value];
347
+ if (activeElement && $list.value) {
348
+ const containerRect = $list.value.getBoundingClientRect();
349
+ const elemRect = activeElement.getBoundingClientRect();
350
+
351
+ // У $list задан paddint-top, получаем его значение
352
+ const paddingTop = Number.parseInt(getComputedStyle($list.value).paddingTop) || 0;
353
+ // Верхняя видимая граница = реальные координаты + отступ
354
+ const visibleTop = containerRect.top + paddingTop;
355
+
356
+ if (focusSuggestIndex.value === 0) {
357
+ $list.value.scrollTop = 0;
358
+ } else if (focusSuggestIndex.value === suggests.value.length - 1) {
359
+ $list.value.scrollTop = $list.value.scrollHeight;
360
+ } else if (elemRect.top < visibleTop) {
361
+ $list.value.scrollTop -= visibleTop - elemRect.top;
362
+ } else if (elemRect.bottom > containerRect.bottom) {
363
+ $list.value.scrollTop += elemRect.bottom - containerRect.bottom;
364
+ }
365
+ }
357
366
  }
367
+
358
368
  if (event.key === 'Enter' && focusSuggestIndex.value >= 0) {
359
- // Ждём, чтобы событие нажатия Enter сработало у других обработчиков (например, в формах)
360
- setTimeout(() => {
361
- select(suggests.value[focusSuggestIndex.value]);
362
- });
369
+ const item = suggests.value[focusSuggestIndex.value];
370
+
371
+ if (item && !item.disabled) {
372
+ // Ждём, чтобы событие нажатия Enter сработало у других обработчиков (например, в формах)
373
+ setTimeout(() => {
374
+ select(item);
375
+ });
376
+ }
363
377
  }
364
378
  }
365
379
  };
@@ -581,8 +581,18 @@ $input-textarea-small-min-height: 72px !default;
581
581
 
582
582
  /********** Поле ввода пароля (.password) **********/
583
583
 
584
- $password-color: $color-grey-40;
585
- $password-active-color: $color-primary-40;
584
+ $password-color: $color-grey-40 !default;
585
+ $password-active-color: $color-primary-40 !default;
586
+
587
+ /********** Поле поиска (.search-input) **********/
588
+
589
+ $search-input-submit-icon-color: $color-grey-70 !default;
590
+ $search-input-submit-icon-active-color: $color-error !default;
591
+
592
+ $search-input-clear-bg: $color-white !default;
593
+ $search-input-clear-radius: $input-radius !default;
594
+ $search-input-clear-color: $color-grey-70 !default;
595
+ $search-input-clear-active-color: $color-grey-40 !default;
586
596
 
587
597
  /********** Поле ввода с подсказкамми (.suggest-input) **********/
588
598
 
@@ -592,6 +602,8 @@ $suggest-input-item-font: ('font-size': $font-size, 'line-height': $line-height)
592
602
  $suggest-input-item-mark-color: inherit !default;
593
603
  $suggest-input-item-mark-font: ('font-weight': bold) !default;
594
604
 
605
+ $suggest-input-item-disabled-color: $font-color-tertiary !default;
606
+
595
607
  $suggest-input-item-active-bg: $color-grey-90 !default;
596
608
  $suggest-input-item-active-color: $font-color-action !default;
597
609