@formixru/b2b-ui 3.6.1 → 4.0.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.6.1",
3
+ "version": "4.0.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,7 +29,7 @@
29
29
  "@advdominion/pluralize": "^1.0.2",
30
30
  "@advdominion/scroll-to": "^1.0.1",
31
31
  "@advdominion/stylelint-config": "^1.1.0",
32
- "@advdominion/tooltip": "^6.0.0",
32
+ "@advdominion/tooltip": "^7.0.1",
33
33
  "@floating-ui/dom": "^1.7.6",
34
34
  "@popperjs/core": "^2.11.8",
35
35
  "@prettier/plugin-php": "^0.25.0",
@@ -73,7 +73,7 @@
73
73
  "@advdominion/get-scrollbar-width": "^2.0.0",
74
74
  "@advdominion/pluralize": "^1.0.0",
75
75
  "@advdominion/scroll-to": "^1.0.0",
76
- "@advdominion/tooltip": "^6.0.0",
76
+ "@advdominion/tooltip": "^7.0.0",
77
77
  "color": "^5.0.0",
78
78
  "dayjs": "^1.0.0",
79
79
  "es-toolkit": "^1.0.0",
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
 
@@ -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
  }
@@ -1,21 +1,21 @@
1
1
  <template>
2
2
  <div ref="$root" class="suggest-input" @keydown.esc="esc()">
3
3
  <input
4
- :id="id"
4
+ :id="props.id"
5
5
  ref="$input"
6
6
  v-model="model"
7
7
  class="suggest-input__input input"
8
8
  :class="[
9
9
  showSuggests ? 'suggest-input__input_active' : '',
10
- small ? 'input_small' : '',
11
- error ? 'input_error' : '',
12
- ...inputCssClasses,
10
+ props.small ? 'input_small' : '',
11
+ props.error ? 'input_error' : '',
12
+ ...props.inputCssClasses,
13
13
  ]"
14
14
  type="text"
15
- :name="name"
16
- :required="required"
17
- :disabled="disabled"
18
- :placeholder="placeholder"
15
+ :name="props.name"
16
+ :required="props.required"
17
+ :disabled="props.disabled"
18
+ :placeholder="props.placeholder"
19
19
  autocomplete="off"
20
20
  @input="handleInput()"
21
21
  @focusin="focusin()"
@@ -36,22 +36,22 @@
36
36
  tabindex="0"
37
37
  @click="select(item)"
38
38
  >
39
- <span v-if="hightlightMatches" v-html="item.formatted_label"></span>
39
+ <span v-if="props.hightlightMatches" v-html="item.formatted_label"></span>
40
40
  <span v-else v-html="item.label"></span>
41
41
  </div>
42
42
  </template>
43
- <div v-else class="suggest-input__not-found" v-html="notFoundMessage"></div>
43
+ <div v-else class="suggest-input__not-found" v-html="props.notFoundMessage"></div>
44
44
  </div>
45
45
  <slot name="content"></slot>
46
46
  </div>
47
47
  </template>
48
48
 
49
49
  <script setup>
50
- /* globals BX */
50
+ import { cssVar } from '@advdominion/css-var';
51
+ import { debounce } from 'es-toolkit';
52
+ import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
51
53
 
52
- import { computed, nextTick, onMounted, ref, toRefs, watch } from 'vue';
53
-
54
- import { errorHandler } from '../../utils/error-handler.js';
54
+ import { request } from '../request/index.js';
55
55
 
56
56
  const ICONS_PATH = window.ICONS_PATH;
57
57
 
@@ -90,9 +90,11 @@ const props = defineProps({
90
90
  type: Number,
91
91
  default: 3,
92
92
  },
93
- throttle: {
93
+ inputDelay: {
94
94
  type: Number,
95
- default: 1000,
95
+ default() {
96
+ return cssVar('--input-delay');
97
+ },
96
98
  },
97
99
  inputCssClasses: {
98
100
  type: Array,
@@ -113,28 +115,30 @@ const props = defineProps({
113
115
  error: {
114
116
  type: Boolean,
115
117
  },
118
+ loaderDelay: {
119
+ type: Number,
120
+ default() {
121
+ return cssVar('--loader-delay-min');
122
+ },
123
+ },
124
+ loaderMinTime: {
125
+ type: Number,
126
+ default() {
127
+ return cssVar('--loader-min-time');
128
+ },
129
+ },
130
+ suggestsTimeout: {
131
+ type: Number,
132
+ default() {
133
+ return cssVar('--fallback-timeout');
134
+ },
135
+ },
116
136
  });
117
137
 
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();
138
+ const $root = useTemplateRef('$root');
139
+ const $input = useTemplateRef('$input');
140
+ const $list = useTemplateRef('$list');
141
+ const $clear = useTemplateRef('$clear');
138
142
 
139
143
  const suggests = ref([]);
140
144
  const formattedSuggests = ref([]);
@@ -144,7 +148,7 @@ watch(suggests, () => {
144
148
  focusSuggestIndex.value = -1;
145
149
  const regexp = new RegExp(model.value, 'i');
146
150
  formattedSuggests.value = suggests.value.map((item) => {
147
- if (hightlightMatches.value) {
151
+ if (props.hightlightMatches) {
148
152
  const match = regexp.exec(item.label);
149
153
  item.formatted_label = match ? item.label.replace(match[0], `<mark>${match[0]}</mark>`) : item.label;
150
154
  }
@@ -191,41 +195,84 @@ const indexesToScroll = computed(() => {
191
195
  });
192
196
 
193
197
  const isLoading = ref(false);
194
- const isClearVisible = computed(() => model.value.length >= minlength.value && !isLoading.value);
198
+ const isClearVisible = computed(() => model.value.length >= props.minlength && !isLoading.value);
195
199
 
196
- let throttleTimeout;
200
+ let controllerDaData;
201
+ let abortTimeout;
202
+ let loaderTimeout;
203
+ let loaderStartTime = 0;
197
204
 
198
- const handleInput = () => {
199
- clearTimeout(throttleTimeout);
200
- if (model.value.length >= minlength.value) {
201
- throttleTimeout = setTimeout(async () => {
205
+ const getSuggests = async (query) => {
206
+ const currentController = new AbortController();
207
+ controllerDaData = currentController;
208
+
209
+ clearTimeout(abortTimeout);
210
+ abortTimeout = setTimeout(() => {
211
+ currentController.abort();
212
+ }, props.suggestsTimeout);
213
+
214
+ // Добавляем небольшую задержку, чтобы лодер не показывался на быстрых ответах
215
+ clearTimeout(loaderTimeout);
216
+ loaderTimeout = setTimeout(() => {
217
+ if (currentController === controllerDaData) {
202
218
  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 {
219
+ loaderStartTime = Date.now();
220
+ }
221
+ }, props.loaderDelay);
222
+
223
+ try {
224
+ const url = new URL(props.action, location.origin);
225
+ url.searchParams.append(props.queryName, query);
226
+
227
+ const data = await request({
228
+ url,
229
+ loader: false,
230
+ interceptData: false,
231
+ signal: currentController.signal,
232
+ });
233
+
234
+ if (currentController !== controllerDaData) {
235
+ return;
236
+ }
237
+
238
+ if (data) {
239
+ suggests.value = data;
240
+ showSuggests.value = true;
241
+ }
242
+ } finally {
243
+ if (currentController === controllerDaData) {
244
+ clearTimeout(abortTimeout);
245
+ controllerDaData = undefined;
246
+
247
+ clearTimeout(loaderTimeout);
248
+ if (isLoading.value) {
249
+ // Если показали лоадер, то убеждаемся, что он будет показываться какое-то мин. время, чтобы избежать миганий
250
+ const displayTime = Date.now() - loaderStartTime;
251
+ const remainingTime = Math.max(0, props.loaderMinTime - displayTime);
252
+ setTimeout(() => {
223
253
  isLoading.value = false;
224
- errorHandler(error);
225
- }
254
+ loaderStartTime = 0;
255
+ }, remainingTime);
226
256
  }
227
- }, throttle.value);
257
+ }
258
+ }
259
+ };
260
+
261
+ const _debouncedGetSuggests = debounce(getSuggests, props.inputDelay);
262
+
263
+ const cancelRequest = () => {
264
+ _debouncedGetSuggests.cancel();
265
+ controllerDaData?.abort();
266
+ clearTimeout(abortTimeout);
267
+ clearTimeout(loaderTimeout);
268
+ isLoading.value = false;
269
+ };
270
+
271
+ const handleInput = () => {
272
+ if (model.value.length >= props.minlength) {
273
+ _debouncedGetSuggests(model.value);
228
274
  } else {
275
+ cancelRequest();
229
276
  showSuggests.value = false;
230
277
  }
231
278
  };
@@ -238,6 +285,7 @@ const focusin = async () => {
238
285
 
239
286
  const focusout = async ({ relatedTarget }) => {
240
287
  if (!relatedTarget || (!$list.value.contains(relatedTarget) && !$clear.value.contains(relatedTarget))) {
288
+ cancelRequest();
241
289
  showSuggests.value = false;
242
290
  }
243
291
  await nextTick();
@@ -245,6 +293,7 @@ const focusout = async ({ relatedTarget }) => {
245
293
  };
246
294
 
247
295
  const esc = () => {
296
+ cancelRequest();
248
297
  // Ждём, чтобы событие нажатия Esc сработало у других обработчиков (например, в диалоговых окнах)
249
298
  setTimeout(() => {
250
299
  showSuggests.value = false;
@@ -252,12 +301,13 @@ const esc = () => {
252
301
  };
253
302
 
254
303
  const clear = () => {
255
- clearTimeout(throttleTimeout);
304
+ cancelRequest();
256
305
  model.value = '';
257
306
  showSuggests.value = false;
258
307
  };
259
308
 
260
309
  const select = ({ label, value }) => {
310
+ cancelRequest();
261
311
  model.value = label;
262
312
  showSuggests.value = false;
263
313
  emit('select', {