@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 +1 -1
- package/src/base.scss +7 -0
- package/src/components/request/index.js +26 -12
- package/src/components/search-input/index.scss +74 -0
- package/src/components/search-input/index.vue +112 -0
- package/src/components/suggest-input/index.scss +9 -12
- package/src/components/suggest-input/index.vue +158 -129
- package/src/variables.scss +12 -2
package/package.json
CHANGED
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 (
|
|
119
|
+
if (loader) {
|
|
106
120
|
hideLoader();
|
|
107
|
-
|
|
121
|
+
hideLoaderOnSuccess = false;
|
|
108
122
|
}
|
|
109
123
|
await showDialog($dialog);
|
|
110
124
|
initRequestContent($dialog);
|
|
111
125
|
} else {
|
|
112
|
-
console.
|
|
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 (
|
|
162
|
+
if (loader) {
|
|
149
163
|
hideLoader();
|
|
150
|
-
|
|
164
|
+
hideLoaderOnSuccess = false;
|
|
151
165
|
}
|
|
152
166
|
await showSidebar($sidebar);
|
|
153
167
|
initRequestContent($sidebar);
|
|
154
168
|
} else {
|
|
155
|
-
console.
|
|
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.
|
|
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 (
|
|
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 -
|
|
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 -
|
|
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:
|
|
69
|
-
width:
|
|
68
|
+
height: 16px;
|
|
69
|
+
width: 16px;
|
|
70
70
|
|
|
71
71
|
.input_small ~ #{$b}__clear & {
|
|
72
|
-
height:
|
|
73
|
-
width:
|
|
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: -
|
|
94
|
-
max-height: $suggest-input-item-height * 5 +
|
|
95
|
-
padding-top:
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
104
|
+
inputDelay: {
|
|
94
105
|
type: Number,
|
|
95
|
-
default
|
|
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: 'По вашему запросу ничего не найдено',
|
|
109
122
|
},
|
|
110
|
-
|
|
111
|
-
type:
|
|
123
|
+
loaderDelay: {
|
|
124
|
+
type: Number,
|
|
125
|
+
default() {
|
|
126
|
+
return cssVar('--loader-delay-min');
|
|
127
|
+
},
|
|
112
128
|
},
|
|
113
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
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
|
|
170
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
191
|
-
});
|
|
236
|
+
};
|
|
192
237
|
|
|
193
|
-
const
|
|
194
|
-
const isClearVisible = computed(() => model.value.length >= minlength.value && !isLoading.value);
|
|
238
|
+
const _debouncedGetSuggests = debounce(getSuggests, props.inputDelay);
|
|
195
239
|
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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 сработало у других обработчиков (например, в формах)
|
package/src/variables.scss
CHANGED
|
@@ -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
|
|