@formixru/b2b-ui 3.7.0 → 4.1.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": "3.7.0",
3
+ "version": "4.1.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
package/src/base.scss CHANGED
@@ -26,6 +26,13 @@
26
26
  --easing-deceleration: cubic-bezier(0, 0, 0.2, 1);
27
27
  --easing-acceleration: cubic-bezier(0.4, 0, 1, 1);
28
28
 
29
+ --input-delay: 300ms;
30
+ --change-delay: 900ms;
31
+ --loader-delay: 600ms;
32
+ --loader-delay-min: 300ms;
33
+ --loader-min-time: 600ms;
34
+ --fallback-timeout: 3000ms;
35
+
29
36
  @each $key, $value in $breakpoints {
30
37
  --breakpoint-#{$key}: #{$value};
31
38
  }
@@ -27,11 +27,24 @@ export const request = async ({
27
27
  url = '',
28
28
  method = 'GET',
29
29
  data = {},
30
+ signal,
30
31
  loader = true,
31
32
  showLoaderDelay,
32
- hideLoaderOnSuccess = true,
33
+ hideLoaderOnSuccess: initialHideLoaderOnSuccess = true,
33
34
  interceptData = true,
35
+ ...unknownParams
34
36
  }) => {
37
+ let hideLoaderOnSuccess = initialHideLoaderOnSuccess;
38
+
39
+ const unknownKeys = Object.keys(unknownParams);
40
+ if (unknownKeys.length > 0) {
41
+ if (unknownKeys.length === 1) {
42
+ console.warn(`В функцию request передан неизвестный параметр: ${unknownKeys.join(', ')}`);
43
+ } else {
44
+ console.warn(`В функцию request переданы неизвестные параметры: ${unknownKeys.join(', ')}`);
45
+ }
46
+ }
47
+
35
48
  const reqUrl = new URL(url, location.origin);
36
49
  const options = {
37
50
  method,
@@ -39,7 +52,9 @@ export const request = async ({
39
52
  'X-Requested-With': 'XMLHttpRequest',
40
53
  'X-Session-Id': BX.message('bitrix_sessid'),
41
54
  },
55
+ signal,
42
56
  };
57
+
43
58
  if (method === 'GET') {
44
59
  let entries;
45
60
  if (data instanceof FormData) {
@@ -60,9 +75,11 @@ export const request = async ({
60
75
  options.headers['Content-Type'] = 'application/json';
61
76
  }
62
77
  }
78
+
63
79
  if (loader) {
64
80
  showLoader(showLoaderDelay);
65
81
  }
82
+
66
83
  try {
67
84
  const response = await fetch(reqUrl, options);
68
85
  if (!response.ok) {
@@ -78,9 +95,6 @@ export const request = async ({
78
95
  data = { content: raw };
79
96
  }
80
97
 
81
- const shouldHideLoader = loader && hideLoaderOnSuccess;
82
- let isLoaderHidden = false;
83
-
84
98
  if (isString(data.redirect)) {
85
99
  if (data.redirect) {
86
100
  location.assign(data.redirect);
@@ -102,14 +116,14 @@ export const request = async ({
102
116
  if ($dialog) {
103
117
  $dialog.querySelector('.dialog__container').innerHTML = data.dialog.content;
104
118
  // Вызываем hideLoader так же как в основном компоненте - до функции show()
105
- if (shouldHideLoader && !isLoaderHidden) {
119
+ if (loader) {
106
120
  hideLoader();
107
- isLoaderHidden = true;
121
+ hideLoaderOnSuccess = false;
108
122
  }
109
123
  await showDialog($dialog);
110
124
  initRequestContent($dialog);
111
125
  } else {
112
- console.error(`Диалоговое окно с id ${elementId?.replace(/^#/, '')} не найдено`);
126
+ console.warn(`Диалоговое окно с id ${elementId?.replace(/^#/, '')} не найдено`);
113
127
  }
114
128
  }
115
129
 
@@ -145,14 +159,14 @@ export const request = async ({
145
159
  if ($sidebar) {
146
160
  $sidebar.innerHTML = data.sidebar.content;
147
161
  // Вызываем hideLoader так же как в основном компоненте - до функции show()
148
- if (shouldHideLoader && !isLoaderHidden) {
162
+ if (loader) {
149
163
  hideLoader();
150
- isLoaderHidden = true;
164
+ hideLoaderOnSuccess = false;
151
165
  }
152
166
  await showSidebar($sidebar);
153
167
  initRequestContent($sidebar);
154
168
  } else {
155
- console.error(`Сайдбар с id ${elementId?.replace(/^#/, '')} не найден`);
169
+ console.warn(`Сайдбар с id ${elementId?.replace(/^#/, '')} не найден`);
156
170
  }
157
171
  }
158
172
 
@@ -187,7 +201,7 @@ export const request = async ({
187
201
  if ($trigger) {
188
202
  $trigger.click();
189
203
  } else {
190
- console.error(`Элемент с селектором ${data.trigger} не найден`);
204
+ console.warn(`Элемент с селектором ${data.trigger} не найден`);
191
205
  if (loader) {
192
206
  hideLoader();
193
207
  }
@@ -195,7 +209,7 @@ export const request = async ({
195
209
  }
196
210
  }
197
211
 
198
- if (shouldHideLoader && !isLoaderHidden) {
212
+ if (loader && hideLoaderOnSuccess) {
199
213
  hideLoader(isString(data.redirect) ? 3000 : undefined);
200
214
  }
201
215
 
@@ -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,112 @@
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
+ @input="emit('input', $event)"
13
+ @focusin="emit('focusin', $event)"
14
+ @focusout="emit('focusout', $event)"
15
+ @keydown="emit('keydown', $event)"
16
+ />
17
+ <button
18
+ class="search-input__submit"
19
+ type="button"
20
+ @click="
21
+ updateModel();
22
+ emit('submit');
23
+ "
24
+ >
25
+ <span v-show="props.isLoading" class="search-input__spinner"><span class="spinner"></span></span>
26
+ <svg v-show="!props.isLoading" class="search-input__submit-icon">
27
+ <use :href="`${ICONS_PATH}#search`" />
28
+ </svg>
29
+ </button>
30
+ <button v-show="query.length > 0" class="search-input__clear" type="button" @click="query = ''">
31
+ <svg class="search-input__clear-icon"><use :href="`${ICONS_PATH}#close`" /></svg>
32
+ </button>
33
+ </div>
34
+ </template>
35
+
36
+ <script setup>
37
+ import { cssVar } from '@advdominion/css-var';
38
+ import { debounce } from 'es-toolkit';
39
+ import { ref, useTemplateRef, watch, watchEffect } from 'vue';
40
+
41
+ const ICONS_PATH = window.ICONS_PATH;
42
+
43
+ const emit = defineEmits(['input', 'focusin', 'focusout', 'keydown', 'submit']);
44
+ const model = defineModel({ type: String, default: '' });
45
+
46
+ const props = defineProps({
47
+ name: {
48
+ type: String,
49
+ default: '',
50
+ },
51
+ placeholder: {
52
+ type: String,
53
+ default: 'Поиск',
54
+ },
55
+ minlength: {
56
+ type: Number,
57
+ default: 3,
58
+ },
59
+ inputDelay: {
60
+ type: Number,
61
+ default() {
62
+ return cssVar('--input-delay');
63
+ },
64
+ },
65
+ inputCssClasses: {
66
+ type: Array,
67
+ default() {
68
+ return [];
69
+ },
70
+ },
71
+ isLoading: {
72
+ type: Boolean,
73
+ },
74
+ });
75
+
76
+ const $root = useTemplateRef('$root');
77
+ const $input = useTemplateRef('$input');
78
+
79
+ const query = ref(model.value);
80
+ watchEffect(() => {
81
+ query.value = model.value;
82
+ });
83
+
84
+ const debouncedUpdateModel = debounce(() => {
85
+ model.value = query.value;
86
+ }, props.inputDelay);
87
+
88
+ const updateModel = () => {
89
+ debouncedUpdateModel.cancel();
90
+
91
+ if (query.value.length === 0) {
92
+ model.value = query.value;
93
+ } else if (query.value.length >= props.minlength) {
94
+ debouncedUpdateModel();
95
+ }
96
+ };
97
+
98
+ watch(query, (newQueryValue) => {
99
+ if (newQueryValue !== model.value) {
100
+ updateModel();
101
+ }
102
+ });
103
+
104
+ const focus = () => {
105
+ $input.value.focus();
106
+ };
107
+
108
+ defineExpose({
109
+ $root,
110
+ focus,
111
+ });
112
+ </script>
@@ -19,7 +19,7 @@ $b: '.suggest-input';
19
19
  border-top-right-radius: $input-radius;
20
20
  container-type: size;
21
21
  height: $input-height - $input-border-width * 2;
22
- padding: math.div($input-height - 4px * 5, 2);
22
+ padding: math.div($input-height - 20px, 2);
23
23
  position: absolute;
24
24
  right: $input-border-width;
25
25
  top: $input-border-width;
@@ -28,7 +28,7 @@ $b: '.suggest-input';
28
28
 
29
29
  .input_small ~ & {
30
30
  height: $input-small-height - $input-border-width * 2;
31
- padding: math.div($input-small-height - 4px * 4, 2);
31
+ padding: math.div($input-small-height - 16px, 2);
32
32
  width: $input-small-height - $input-border-width * 2;
33
33
  }
34
34
  }
@@ -65,12 +65,12 @@ $b: '.suggest-input';
65
65
 
66
66
  &__clear-icon {
67
67
  fill: currentcolor;
68
- height: 4px * 4;
69
- width: 4px * 4;
68
+ height: 16px;
69
+ width: 16px;
70
70
 
71
71
  .input_small ~ #{$b}__clear & {
72
- height: 4px * 3;
73
- width: 4px * 3;
72
+ height: 12px;
73
+ width: 12px;
74
74
  }
75
75
  }
76
76
 
@@ -90,9 +90,9 @@ $b: '.suggest-input';
90
90
  @include dark-border-disable((top));
91
91
 
92
92
  @if $input-radius != 0 {
93
- margin-top: -4px * 2;
94
- max-height: $suggest-input-item-height * 5 + 4px * 2;
95
- padding-top: 4px * 2;
93
+ margin-top: -8px;
94
+ max-height: $suggest-input-item-height * 5 + 8px;
95
+ padding-top: 8px;
96
96
  } @else {
97
97
  max-height: $suggest-input-item-height * 5;
98
98
  }
@@ -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
 
@@ -1,29 +1,39 @@
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
+ @input="handleInput()"
14
+ @focusin="focusin()"
15
+ @focusout="focusout($event)"
16
+ @keydown="arrowNavigation($event)"
17
+ />
3
18
  <input
4
- :id="id"
5
- ref="$input"
19
+ v-else
20
+ :id="props.id"
6
21
  v-model="model"
7
22
  class="suggest-input__input input"
8
- :class="[
9
- showSuggests ? 'suggest-input__input_active' : '',
10
- small ? 'input_small' : '',
11
- error ? 'input_error' : '',
12
- ...inputCssClasses,
13
- ]"
23
+ :class="[showSuggests ? 'suggest-input__input_active' : '', ...props.inputCssClasses]"
14
24
  type="text"
15
- :name="name"
16
- :required="required"
17
- :disabled="disabled"
18
- :placeholder="placeholder"
25
+ :name="props.name"
26
+ :required="props.required"
27
+ :disabled="props.disabled"
28
+ :placeholder="props.placeholder"
19
29
  autocomplete="off"
20
30
  @input="handleInput()"
21
31
  @focusin="focusin()"
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 v-show="isClearVisible && !props.searchInput" ref="$clear" class="suggest-input__clear" type="button" @click="clear()">
27
37
  <svg class="suggest-input__clear-icon"><use :href="`${ICONS_PATH}#close`" /></svg>
28
38
  </button>
29
39
  <div v-show="showSuggests" ref="$list" class="suggest-input__list" @mouseover="focusSuggestIndex = -1">
@@ -36,22 +46,23 @@
36
46
  tabindex="0"
37
47
  @click="select(item)"
38
48
  >
39
- <span v-if="hightlightMatches" v-html="item.formatted_label"></span>
49
+ <span v-if="props.hightlightMatches" v-html="item.formatted_label"></span>
40
50
  <span v-else v-html="item.label"></span>
41
51
  </div>
42
52
  </template>
43
- <div v-else class="suggest-input__not-found" v-html="notFoundMessage"></div>
53
+ <div v-else class="suggest-input__not-found" v-html="props.notFoundMessage"></div>
44
54
  </div>
45
55
  <slot name="content"></slot>
46
56
  </div>
47
57
  </template>
48
58
 
49
59
  <script setup>
50
- /* globals BX */
60
+ import { cssVar } from '@advdominion/css-var';
61
+ import { debounce } from 'es-toolkit';
62
+ import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
51
63
 
52
- import { computed, nextTick, onMounted, ref, toRefs, watch } from 'vue';
53
-
54
- import { errorHandler } from '../../utils/error-handler.js';
64
+ import { request } from '../request/index.js';
65
+ import SearchInput from '../search-input/index.vue';
55
66
 
56
67
  const ICONS_PATH = window.ICONS_PATH;
57
68
 
@@ -90,9 +101,11 @@ const props = defineProps({
90
101
  type: Number,
91
102
  default: 3,
92
103
  },
93
- throttle: {
104
+ inputDelay: {
94
105
  type: Number,
95
- default: 1000,
106
+ default() {
107
+ return cssVar('--input-delay');
108
+ },
96
109
  },
97
110
  inputCssClasses: {
98
111
  type: Array,
@@ -107,34 +120,32 @@ const props = defineProps({
107
120
  type: String,
108
121
  default: 'По&nbsp;вашему запросу ничего не&nbsp;найдено',
109
122
  },
110
- small: {
111
- type: Boolean,
123
+ loaderDelay: {
124
+ type: Number,
125
+ default() {
126
+ return cssVar('--loader-delay-min');
127
+ },
112
128
  },
113
- error: {
129
+ loaderMinTime: {
130
+ type: Number,
131
+ default() {
132
+ return cssVar('--loader-min-time');
133
+ },
134
+ },
135
+ suggestsTimeout: {
136
+ type: Number,
137
+ default() {
138
+ return cssVar('--fallback-timeout');
139
+ },
140
+ },
141
+ searchInput: {
114
142
  type: Boolean,
115
143
  },
116
144
  });
117
145
 
118
- const {
119
- id,
120
- name,
121
- placeholder,
122
- required,
123
- disabled,
124
- action,
125
- queryName,
126
- minlength,
127
- throttle,
128
- inputCssClasses,
129
- hightlightMatches,
130
- small,
131
- notFoundMessage,
132
- } = toRefs(props);
133
-
134
- const $root = ref();
135
- const $input = ref();
136
- const $list = ref();
137
- const $clear = ref();
146
+ const $root = useTemplateRef('$root');
147
+ const $list = useTemplateRef('$list');
148
+ const $clear = useTemplateRef('$clear');
138
149
 
139
150
  const suggests = ref([]);
140
151
  const formattedSuggests = ref([]);
@@ -144,7 +155,7 @@ watch(suggests, () => {
144
155
  focusSuggestIndex.value = -1;
145
156
  const regexp = new RegExp(model.value, 'i');
146
157
  formattedSuggests.value = suggests.value.map((item) => {
147
- if (hightlightMatches.value) {
158
+ if (props.hightlightMatches) {
148
159
  const match = regexp.exec(item.label);
149
160
  item.formatted_label = match ? item.label.replace(match[0], `<mark>${match[0]}</mark>`) : item.label;
150
161
  }
@@ -153,79 +164,92 @@ watch(suggests, () => {
153
164
  });
154
165
 
155
166
  const showSuggests = ref(false);
156
- const itemHeight = ref(0);
157
167
 
158
168
  watch(showSuggests, async (value) => {
159
- if (value && suggests.value.length > 0) {
160
- await nextTick();
161
- itemHeight.value = $list.value.querySelector('.suggest-input__list-item').offsetHeight;
162
- } else {
169
+ if (!value) {
163
170
  suggests.value = [];
164
- await nextTick();
165
- itemHeight.value = 0;
166
171
  }
167
172
  });
168
173
 
169
- const maxItemsWithoutScroll = computed(() => {
170
- if (itemHeight.value) {
171
- return Math.floor($list.value.offsetHeight / itemHeight.value);
172
- }
173
- return 0;
174
- });
174
+ const isLoading = ref(false);
175
+ const isClearVisible = computed(() => model.value.length >= props.minlength && !isLoading.value);
175
176
 
176
- const indexesToScroll = computed(() => {
177
- if (maxItemsWithoutScroll.value) {
178
- const indexes = [0];
179
- for (const index of suggests.value.keys()) {
180
- const nextIndex = indexes.at(-1) + maxItemsWithoutScroll.value;
181
- if (nextIndex > suggests.value.length) {
182
- break;
183
- }
184
- if (index === nextIndex) {
185
- indexes.push(index);
177
+ let controllerDaData;
178
+ let abortTimeout;
179
+ let loaderTimeout;
180
+ let loaderStartTime = 0;
181
+
182
+ const getSuggests = async (query) => {
183
+ const currentController = new AbortController();
184
+ controllerDaData = currentController;
185
+
186
+ clearTimeout(abortTimeout);
187
+ abortTimeout = setTimeout(() => {
188
+ currentController.abort();
189
+ }, props.suggestsTimeout);
190
+
191
+ // Добавляем небольшую задержку, чтобы лодер не показывался на быстрых ответах
192
+ clearTimeout(loaderTimeout);
193
+ loaderTimeout = setTimeout(() => {
194
+ if (currentController === controllerDaData) {
195
+ isLoading.value = true;
196
+ loaderStartTime = Date.now();
197
+ }
198
+ }, props.loaderDelay);
199
+
200
+ try {
201
+ const url = new URL(props.action, location.origin);
202
+ url.searchParams.append(props.queryName, query);
203
+
204
+ const data = await request({
205
+ url,
206
+ loader: false,
207
+ interceptData: false,
208
+ signal: currentController.signal,
209
+ });
210
+
211
+ if (currentController !== controllerDaData) {
212
+ return;
213
+ }
214
+
215
+ if (data) {
216
+ suggests.value = data;
217
+ showSuggests.value = true;
218
+ }
219
+ } finally {
220
+ if (currentController === controllerDaData) {
221
+ clearTimeout(abortTimeout);
222
+ controllerDaData = undefined;
223
+
224
+ clearTimeout(loaderTimeout);
225
+ if (isLoading.value) {
226
+ // Если показали лоадер, то убеждаемся, что он будет показываться какое-то мин. время, чтобы избежать миганий
227
+ const displayTime = Date.now() - loaderStartTime;
228
+ const remainingTime = Math.max(0, props.loaderMinTime - displayTime);
229
+ setTimeout(() => {
230
+ isLoading.value = false;
231
+ loaderStartTime = 0;
232
+ }, remainingTime);
186
233
  }
187
234
  }
188
- return indexes;
189
235
  }
190
- return [];
191
- });
236
+ };
192
237
 
193
- const isLoading = ref(false);
194
- const isClearVisible = computed(() => model.value.length >= minlength.value && !isLoading.value);
238
+ const _debouncedGetSuggests = debounce(getSuggests, props.inputDelay);
195
239
 
196
- let throttleTimeout;
240
+ const cancelRequest = () => {
241
+ _debouncedGetSuggests.cancel();
242
+ controllerDaData?.abort();
243
+ clearTimeout(abortTimeout);
244
+ clearTimeout(loaderTimeout);
245
+ isLoading.value = false;
246
+ };
197
247
 
198
248
  const handleInput = () => {
199
- clearTimeout(throttleTimeout);
200
- if (model.value.length >= minlength.value) {
201
- throttleTimeout = setTimeout(async () => {
202
- isLoading.value = true;
203
- const url = new URL(action.value, location.origin);
204
- url.searchParams.append(queryName.value, model.value);
205
- try {
206
- const response = await fetch(url, {
207
- headers: {
208
- 'X-Requested-With': 'XMLHttpRequest',
209
- 'X-Session-Id': BX.message('bitrix_sessid'),
210
- },
211
- });
212
- if (!response.ok) {
213
- throw response;
214
- }
215
- const data = await response.json();
216
- isLoading.value = false;
217
- suggests.value = data;
218
- showSuggests.value = true;
219
- } catch (error) {
220
- if (error.status === 401) {
221
- location.reload();
222
- } else {
223
- isLoading.value = false;
224
- errorHandler(error);
225
- }
226
- }
227
- }, throttle.value);
249
+ if (model.value.length >= props.minlength) {
250
+ _debouncedGetSuggests(model.value);
228
251
  } else {
252
+ cancelRequest();
229
253
  showSuggests.value = false;
230
254
  }
231
255
  };
@@ -238,6 +262,7 @@ const focusin = async () => {
238
262
 
239
263
  const focusout = async ({ relatedTarget }) => {
240
264
  if (!relatedTarget || (!$list.value.contains(relatedTarget) && !$clear.value.contains(relatedTarget))) {
265
+ cancelRequest();
241
266
  showSuggests.value = false;
242
267
  }
243
268
  await nextTick();
@@ -245,6 +270,7 @@ const focusout = async ({ relatedTarget }) => {
245
270
  };
246
271
 
247
272
  const esc = () => {
273
+ cancelRequest();
248
274
  // Ждём, чтобы событие нажатия Esc сработало у других обработчиков (например, в диалоговых окнах)
249
275
  setTimeout(() => {
250
276
  showSuggests.value = false;
@@ -252,12 +278,13 @@ const esc = () => {
252
278
  };
253
279
 
254
280
  const clear = () => {
255
- clearTimeout(throttleTimeout);
281
+ cancelRequest();
256
282
  model.value = '';
257
283
  showSuggests.value = false;
258
284
  };
259
285
 
260
286
  const select = ({ label, value }) => {
287
+ cancelRequest();
261
288
  model.value = label;
262
289
  showSuggests.value = false;
263
290
  emit('select', {
@@ -266,44 +293,46 @@ const select = ({ label, value }) => {
266
293
  });
267
294
  };
268
295
 
269
- const arrowNavigation = (event) => {
296
+ const arrowNavigation = async (event) => {
270
297
  if (showSuggests.value) {
271
298
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
272
- const selectionStart = $input.value.selectionStart;
273
- const selectionEnd = $input.value.selectionEnd;
274
- setTimeout(() => {
275
- $input.value.setSelectionRange(selectionStart, selectionEnd);
276
- });
299
+ event.preventDefault();
300
+
277
301
  switch (event.key) {
278
302
  case 'ArrowDown': {
279
303
  focusSuggestIndex.value =
280
304
  suggests.value.length === focusSuggestIndex.value + 1 ? 0 : focusSuggestIndex.value + 1;
281
- if (
282
- focusSuggestIndex.value !== 0 &&
283
- focusSuggestIndex.value !== suggests.value.length - 1 &&
284
- indexesToScroll.value.includes(focusSuggestIndex.value)
285
- ) {
286
- $list.value.scrollTop += maxItemsWithoutScroll.value * itemHeight.value;
287
- } else if (focusSuggestIndex.value === 0) {
288
- $list.value.scrollTop = 0;
289
- }
290
305
  break;
291
306
  }
292
307
  case 'ArrowUp': {
293
308
  focusSuggestIndex.value =
294
309
  focusSuggestIndex.value <= 0 ? suggests.value.length - 1 : focusSuggestIndex.value - 1;
295
- if (
296
- focusSuggestIndex.value !== 0 &&
297
- focusSuggestIndex.value !== suggests.value.length - 1 &&
298
- indexesToScroll.value.includes(focusSuggestIndex.value + 1)
299
- ) {
300
- $list.value.scrollTop -= maxItemsWithoutScroll.value * itemHeight.value;
301
- } else if (focusSuggestIndex.value === suggests.value.length - 1) {
302
- $list.value.scrollTop = suggests.value.length * itemHeight.value;
303
- }
304
310
  break;
305
311
  }
306
312
  }
313
+
314
+ await nextTick();
315
+
316
+ const activeElement = $list.value?.querySelectorAll('.suggest-input__list-item')[focusSuggestIndex.value];
317
+ if (activeElement && $list.value) {
318
+ const containerRect = $list.value.getBoundingClientRect();
319
+ const elemRect = activeElement.getBoundingClientRect();
320
+
321
+ // У $list задан paddint-top, получаем его значение
322
+ const paddingTop = Number.parseInt(getComputedStyle($list.value).paddingTop) || 0;
323
+ // Верхняя видимая граница = реальные координаты + отступ
324
+ const visibleTop = containerRect.top + paddingTop;
325
+
326
+ if (focusSuggestIndex.value === 0) {
327
+ $list.value.scrollTop = 0;
328
+ } else if (focusSuggestIndex.value === suggests.value.length - 1) {
329
+ $list.value.scrollTop = $list.value.scrollHeight;
330
+ } else if (elemRect.top < visibleTop) {
331
+ $list.value.scrollTop -= visibleTop - elemRect.top;
332
+ } else if (elemRect.bottom > containerRect.bottom) {
333
+ $list.value.scrollTop += elemRect.bottom - containerRect.bottom;
334
+ }
335
+ }
307
336
  }
308
337
  if (event.key === 'Enter' && focusSuggestIndex.value >= 0) {
309
338
  // Ждём, чтобы событие нажатия Enter сработало у других обработчиков (например, в формах)
@@ -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