@formixru/b2b-ui 3.0.2 → 3.0.4

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.
@@ -0,0 +1,333 @@
1
+ <template>
2
+ <div ref="$root" class="file-input" :class="{ 'file-input_dragover': isDragOver }">
3
+ <input type="hidden" :name="name" :value="filesList" />
4
+ <input
5
+ :id="id"
6
+ ref="$input"
7
+ class="sr-only"
8
+ type="file"
9
+ :multiple="maxCount > 1"
10
+ :accept="accept"
11
+ @change="handleFiles($event.target.files)"
12
+ />
13
+ <button class="button button_alt" type="button" :disabled="disabled || maxCount < 1" @click="selectFiles()">
14
+ <slot v-if="$slots.label" name="label"></slot>
15
+ <template v-else-if="maxCount > 1">Выберите файлы</template>
16
+ <template v-else>Выберите файл</template>
17
+ </button>
18
+ <div v-if="descr" class="file-input__descr" v-html="descr"></div>
19
+ <div class="file-input__file-list">
20
+ <div
21
+ v-for="(file, index) in files"
22
+ :key="index"
23
+ class="file-input__file"
24
+ :class="{ 'file-input__file_active': file.uploaded && !file.error }"
25
+ >
26
+ <div class="file-input__file-wrapper">
27
+ <div class="file-input__file-name">{{ file.name }}</div>
28
+ <div v-if="file.error" class="file-input__file-error">{{ file.error }}</div>
29
+ </div>
30
+ <button
31
+ v-if="file.uploaded || file.error"
32
+ class="file-input__remove"
33
+ type="button"
34
+ @click="removeFile(index)"
35
+ >
36
+ <svg class="file-input__icon"><use :href="`${ICONS_PATH}#close`" /></svg>
37
+ </button>
38
+ <div v-else class="file-input__loader">
39
+ <div class="spinner"></div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </template>
45
+
46
+ <script setup>
47
+ /* globals BX */
48
+
49
+ import { pluralize } from '@advdominion/pluralize';
50
+ import mime from 'mime';
51
+ import { computed, nextTick, onMounted, ref, toRefs } from 'vue';
52
+
53
+ const ICONS_PATH = window.ICONS_PATH;
54
+
55
+ const emit = defineEmits(['upload']);
56
+
57
+ const props = defineProps({
58
+ actionUpload: {
59
+ type: String,
60
+ required: true,
61
+ },
62
+ actionRemove: {
63
+ type: String,
64
+ required: true,
65
+ },
66
+ id: {
67
+ type: String,
68
+ default: '',
69
+ },
70
+ name: {
71
+ type: String,
72
+ default: '',
73
+ },
74
+ value: {
75
+ type: String,
76
+ default: '',
77
+ },
78
+ disabled: {
79
+ type: Boolean,
80
+ },
81
+ accept: {
82
+ type: String,
83
+ default: '',
84
+ },
85
+ maxCount: {
86
+ type: Number,
87
+ default: 1,
88
+ },
89
+ maxSize: {
90
+ type: Number,
91
+ default: 0,
92
+ },
93
+ });
94
+
95
+ const { actionUpload, actionRemove, id, name, value, disabled, accept, maxCount, maxSize } = toRefs(props);
96
+ const files = ref([]);
97
+ const isDragOver = ref(false);
98
+ const maxSizeInBytes = computed(() => maxSize.value * 1024 * 1024);
99
+ const filesList = computed(() => {
100
+ return files.value
101
+ .filter(({ uploaded, error }) => uploaded && !error)
102
+ .map(({ name }) => name)
103
+ .join(',');
104
+ });
105
+ const acceptTypes = computed(() => {
106
+ if (accept.value) {
107
+ return accept.value
108
+ .split(',')
109
+ .map((ext) => mime.getType(ext.trim()))
110
+ .filter(Boolean) // Убираем MIME-типы, которые не были определены
111
+ .filter((value, index, array) => array.indexOf(value) === index); // Убираем одинаковые MIME-типы (например, если указны .jpg и .jpeg)
112
+ }
113
+ return [];
114
+ });
115
+ const descr = computed(() => {
116
+ const descr = [];
117
+ if (maxCount.value > 1) {
118
+ descr.push(`До&nbsp;${maxCount.value} ${pluralize(maxCount.value, ['файла', 'файлов', 'файлов'])}`);
119
+ }
120
+ if (maxSize.value) {
121
+ const formattedMaxSize = new Intl.NumberFormat().format(maxSize.value);
122
+ if (descr.length === 0) {
123
+ descr.push(`Размер файла&nbsp;— до&nbsp;${formattedMaxSize}&nbsp;МБ`);
124
+ } else {
125
+ descr.push(`Размер каждого&nbsp;— до&nbsp;${formattedMaxSize}&nbsp;МБ`);
126
+ }
127
+ }
128
+ if (acceptTypes.value.length > 0) {
129
+ const ext = acceptTypes.value.map((type) => mime.getExtension(type).toUpperCase());
130
+ if (descr.length === 0) {
131
+ descr.push(ext.join(', '));
132
+ } else {
133
+ descr.push(`Тип ${maxCount.value === 1 ? 'файла' : 'файлов'}&nbsp;— ${ext.join(', ')}`);
134
+ if (descr.length === 3) {
135
+ descr[2] = `<br>${descr[2]}`;
136
+ }
137
+ }
138
+ }
139
+ return descr.join('. ');
140
+ });
141
+ const $root = ref();
142
+ const $input = ref();
143
+
144
+ if (value.value) {
145
+ for (const name of value.value.split(',')) {
146
+ if (files.value.length < maxCount.value) {
147
+ files.value.push({
148
+ name: name.trim(),
149
+ uploaded: true,
150
+ error: false,
151
+ });
152
+ }
153
+ }
154
+ }
155
+
156
+ const selectFiles = () => {
157
+ $input.value.click();
158
+ };
159
+
160
+ const addFiles = (fileList) => {
161
+ for (const file of fileList) {
162
+ if (files.value.length < maxCount.value && !files.value.some(({ name }) => name === file.name)) {
163
+ let error = '';
164
+ if (acceptTypes.value.length > 0 && !acceptTypes.value.includes(file.type)) {
165
+ error = 'Этот тип файлов не поддерживается';
166
+ } else if (maxSizeInBytes.value && file.size > maxSizeInBytes.value) {
167
+ error = `Максимальный размер файла — ${maxSize.value} МБ`;
168
+ }
169
+ files.value.push({
170
+ data: file,
171
+ name: file.name,
172
+ uploaded: false,
173
+ error,
174
+ });
175
+ }
176
+ }
177
+ };
178
+
179
+ const uploadFile = async (file) => {
180
+ const index = files.value.findIndex(({ name }) => name === file.name);
181
+ const formData = new FormData();
182
+ formData.append('name', file.name);
183
+ formData.append('file', file);
184
+ try {
185
+ const response = await fetch(actionUpload.value, {
186
+ method: 'POST',
187
+ headers: {
188
+ 'X-Requested-With': 'XMLHttpRequest',
189
+ 'X-Session-Id': BX.message('bitrix_sessid'),
190
+ },
191
+ body: formData,
192
+ });
193
+ if (!response.ok) {
194
+ throw response;
195
+ }
196
+ files.value[index].uploaded = true;
197
+ await nextTick();
198
+ emit('upload');
199
+ } catch (error) {
200
+ if (error.status === 401) {
201
+ location.reload();
202
+ } else {
203
+ console.error(error);
204
+ files.value[index].error = `Ошибка загрузки файла (код ${error.status})`;
205
+ }
206
+ }
207
+ };
208
+
209
+ const removeFile = async (index) => {
210
+ const file = files.value[index];
211
+ file.uploaded = false;
212
+ const formData = new FormData();
213
+ formData.append('name', file.name);
214
+ try {
215
+ const response = await fetch(actionRemove.value, {
216
+ method: 'POST',
217
+ headers: {
218
+ 'X-Requested-With': 'XMLHttpRequest',
219
+ 'X-Session-Id': BX.message('bitrix_sessid'),
220
+ },
221
+ body: formData,
222
+ });
223
+ if (!response.ok) {
224
+ throw response;
225
+ }
226
+ files.value.splice(index, 1);
227
+ } catch (error) {
228
+ if (error.status === 401) {
229
+ location.reload();
230
+ } else {
231
+ console.error(error);
232
+ file.uploaded = true;
233
+ file.error = `Ошибка удаления файла (код ${error.status})`;
234
+ }
235
+ }
236
+ };
237
+
238
+ const clearFiles = (files) => {
239
+ return Promise.all(
240
+ files.map(async ({ name }) => {
241
+ const formData = new FormData();
242
+ formData.append('name', name);
243
+ try {
244
+ const response = await fetch(actionRemove.value, {
245
+ method: 'POST',
246
+ headers: {
247
+ 'X-Requested-With': 'XMLHttpRequest',
248
+ 'X-Session-Id': BX.message('bitrix_sessid'),
249
+ },
250
+ body: formData,
251
+ });
252
+ if (!response.ok) {
253
+ throw response;
254
+ }
255
+ } catch (error) {
256
+ if (error.status === 401) {
257
+ location.reload();
258
+ }
259
+ throw error;
260
+ }
261
+ }),
262
+ );
263
+ };
264
+
265
+ const handleFiles = async (fileList) => {
266
+ // Если уже было выбрано максимальное кол-во файлов, при выборе новых - удаляем выбранные ранее
267
+ if (files.value.length === maxCount.value) {
268
+ const filesToRemove = [...files.value];
269
+ files.value = [];
270
+ addFiles(fileList);
271
+ try {
272
+ await clearFiles(filesToRemove);
273
+ for (const file of files.value) {
274
+ if (!file.error) {
275
+ uploadFile(file.data);
276
+ }
277
+ }
278
+ } catch (error) {
279
+ console.error(error);
280
+ for (const [index] of files.value.entries()) {
281
+ files.value[index].error = `Ошибка добавления файла (код ${error.status})`;
282
+ }
283
+ }
284
+ } else {
285
+ addFiles(fileList);
286
+ for (const file of files.value) {
287
+ if (!file.uploaded && !file.error) {
288
+ uploadFile(file.data);
289
+ }
290
+ }
291
+ }
292
+ $input.value.value = '';
293
+ };
294
+
295
+ onMounted(() => {
296
+ $root.value.addEventListener('dragenter', (e) => {
297
+ if ($root.value.contains(e.target) && !e.relatedTarget.closest('.file-input')) {
298
+ isDragOver.value = true;
299
+ }
300
+ });
301
+ $root.value.addEventListener('dragleave', (e) => {
302
+ if (e.target === $root.value && !e.relatedTarget.closest('.file-input')) {
303
+ isDragOver.value = false;
304
+ }
305
+ });
306
+ $root.value.addEventListener('dragover', (e) => {
307
+ e.stopPropagation();
308
+ e.preventDefault();
309
+ });
310
+ $root.value.addEventListener('drop', (e) => {
311
+ e.stopPropagation();
312
+ e.preventDefault();
313
+ isDragOver.value = false;
314
+ handleFiles(e.dataTransfer.files);
315
+ });
316
+
317
+ // Удаляем выбранные файлы, если человек закроет диалоговое окно
318
+ const $dialog = $root.value.closest('.dialog');
319
+ if ($dialog) {
320
+ $dialog.addEventListener(
321
+ 'dialog-hide',
322
+ () => {
323
+ if (files.value.length > 0) {
324
+ clearFiles(files.value);
325
+ }
326
+ },
327
+ {
328
+ once: true,
329
+ },
330
+ );
331
+ }
332
+ });
333
+ </script>
@@ -0,0 +1,174 @@
1
+ <template>
2
+ <div ref="$root" class="font-input">
3
+ <div
4
+ v-for="(font, index) in parsedFont"
5
+ v-show="visibleFontIndex === index"
6
+ :key="index"
7
+ class="font-input__list"
8
+ >
9
+ <label class="font-input__item">
10
+ <span class="font-input__label">Стиль</span>
11
+ <select v-model="font['font-style']" class="font-input__select select" @change="setFont()">
12
+ <option value=""></option>
13
+ <option value="normal">обычное</option>
14
+ <option value="italic">курсив</option>
15
+ </select>
16
+ </label>
17
+ <label class="font-input__item">
18
+ <span class="font-input__label">Начертание</span>
19
+ <select v-model="font['font-weight']" class="font-input__select select" @change="setFont()">
20
+ <option value=""></option>
21
+ <option value="normal">обычное</option>
22
+ <option value="bold">жирное</option>
23
+ </select>
24
+ </label>
25
+ <label class="font-input__item">
26
+ <span class="font-input__label">Размер</span>
27
+ <select v-model="font['font-size']" class="font-input__select select" @change="setFont()">
28
+ <option value=""></option>
29
+ <option v-for="(fontSizeValue, key) in fontSizeMap" :key="key" :value="fontSizeValue">
30
+ {{ fontSizeValue.replace('px', '') }}
31
+ </option>
32
+ </select>
33
+ </label>
34
+ <label class="font-input__item">
35
+ <span class="font-input__label">Интерлиньяж</span>
36
+ <select v-model="font['line-height']" class="font-input__select select" @change="setFont()">
37
+ <option value=""></option>
38
+ <option v-for="(lineHeightValue, key) in lineHeightMap" :key="key" :value="lineHeightValue">
39
+ {{ lineHeightValue }}
40
+ </option>
41
+ </select>
42
+ </label>
43
+ </div>
44
+ <input ref="$input" type="hidden" :name="name" :value="currentFont" />
45
+ </div>
46
+ </template>
47
+
48
+ <script setup>
49
+ import { nextTick, onMounted, ref, toRefs, watch } from 'vue';
50
+
51
+ import { mm } from '../../utils/mm.js';
52
+
53
+ const emit = defineEmits(['input']);
54
+
55
+ const props = defineProps({
56
+ name: {
57
+ type: String,
58
+ required: true,
59
+ },
60
+ value: {
61
+ type: String,
62
+ required: true,
63
+ },
64
+ });
65
+
66
+ const { name, value } = toRefs(props);
67
+ const currentFont = ref(value.value);
68
+ const parsedFont = ref([
69
+ {
70
+ 'font-style': '',
71
+ 'font-weight': '',
72
+ 'font-size': '',
73
+ 'line-height': '',
74
+ },
75
+ {
76
+ 'font-style': '',
77
+ 'font-weight': '',
78
+ 'font-size': '',
79
+ 'line-height': '',
80
+ },
81
+ ]);
82
+ const visibleFontIndex = ref(0);
83
+ const $root = ref();
84
+ const $input = ref();
85
+
86
+ const fontSizeMap = {
87
+ '$font-size-3xs': '8px',
88
+ '$font-size-2xs': '10px',
89
+ '$font-size-xs': '12px',
90
+ '$font-size-s': '14px',
91
+ '$font-size': '16px',
92
+ '$font-size-l': '20px',
93
+ '$font-size-xl': '24px',
94
+ '$font-size-2xl': '28px',
95
+ };
96
+
97
+ const lineHeightMap = {
98
+ '$line-height-s': 1.2,
99
+ '$line-height': 1.4,
100
+ '$line-height-l': 1.6,
101
+ };
102
+
103
+ const parseFont = () => {
104
+ for (const [index, font] of currentFont.value.split(/, ?(?=\()/).entries()) {
105
+ const parsed = font.match(/'([\dA-Za-z-]+)': (\$?[\dA-Za-z-]+)/g) || [];
106
+ for (let item of parsed) {
107
+ item = item.replaceAll(/'| /g, '').split(':');
108
+ let value = item[1];
109
+
110
+ // Преобразуем переменные в значения
111
+ if (item[0] === 'font-size' && fontSizeMap[value]) {
112
+ value = fontSizeMap[value];
113
+ } else if (item[0] === 'line-height' && lineHeightMap[value]) {
114
+ value = lineHeightMap[value];
115
+ }
116
+
117
+ parsedFont.value[index][item[0]] = value;
118
+ }
119
+ }
120
+ };
121
+
122
+ const setFont = async () => {
123
+ currentFont.value = parsedFont.value
124
+ .map((font) => {
125
+ const fontValue = Object.entries(font)
126
+ .map(([name, value]) => {
127
+ if (String(value).length > 0) {
128
+ return `'${name}': ${value}`;
129
+ }
130
+ return false;
131
+ })
132
+ .filter(Boolean);
133
+ return `(${fontValue.join(', ')})`;
134
+ })
135
+ .join(',');
136
+ await nextTick();
137
+ $input.value.dispatchEvent(new Event('change', { bubbles: true }));
138
+ emit('input', currentFont.value);
139
+ };
140
+
141
+ watch(value, (newValue) => {
142
+ currentFont.value = newValue;
143
+ });
144
+
145
+ watch(currentFont, () => {
146
+ parseFont();
147
+ });
148
+
149
+ parseFont();
150
+
151
+ onMounted(() => {
152
+ if (name.value.split(',').length > 1) {
153
+ const $label = $root.value.closest('.form__item').querySelector('.form__label');
154
+ $label.innerHTML = `${$label.innerHTML}&nbsp;<button class="link link_grey link_dotted" type="button" disabled style="font-weight: bold;">основной</button>&nbsp;/&nbsp;<button class="link link_grey link_dotted" type="button">для больших экранов</button>`;
155
+ $label.style.flexWrap = mm('phone-only') ? 'wrap' : '';
156
+ const $buttons = $label.querySelectorAll('button');
157
+ for (const $button of $buttons) {
158
+ $button.addEventListener('click', (e) => {
159
+ const index = [...e.currentTarget.parentNode.children].indexOf(e.currentTarget);
160
+ for (const [i, $button] of $buttons.entries()) {
161
+ if (i === index) {
162
+ visibleFontIndex.value = index;
163
+ $button.disabled = true;
164
+ $button.style.fontWeight = 'bold';
165
+ } else {
166
+ $button.disabled = false;
167
+ $button.style.fontWeight = '';
168
+ }
169
+ }
170
+ });
171
+ }
172
+ }
173
+ });
174
+ </script>
@@ -0,0 +1,143 @@
1
+ <template>
2
+ <div
3
+ class="quantity-input"
4
+ :class="{ 'quantity-input_medium': size === 'medium', 'quantity-input_big': size === 'big' }"
5
+ >
6
+ <button
7
+ class="quantity-input__button quantity-input__button_minus"
8
+ type="button"
9
+ :disabled="(disabled && !focus) || quantity <= min"
10
+ @click="input(Number((quantity - step).toFixed(digits)))"
11
+ >
12
+ <svg class="quantity-input__icon"><use :href="`${ICONS_PATH}#minus`" /></svg>
13
+ </button>
14
+ <input
15
+ class="quantity-input__input"
16
+ type="number"
17
+ :name="name"
18
+ :value="quantity"
19
+ :disabled="(disabled && !focus) || auth.length > 0"
20
+ @input="input($event.target.value)"
21
+ />
22
+ <button
23
+ class="quantity-input__button quantity-input__button_plus"
24
+ type="button"
25
+ :disabled="(disabled && !focus) || quantity >= max"
26
+ @click="input(Number((quantity + step).toFixed(digits)))"
27
+ >
28
+ <svg class="quantity-input__icon"><use :href="`${ICONS_PATH}#plus`" /></svg>
29
+ </button>
30
+ </div>
31
+ </template>
32
+
33
+ <script setup>
34
+ import { computed, ref, toRefs, watch } from 'vue';
35
+
36
+ import { show as showDialog } from '../dialog/index.js';
37
+
38
+ const ICONS_PATH = window.ICONS_PATH;
39
+
40
+ const emit = defineEmits(['input', 'disableOthers', 'enableOthers']);
41
+
42
+ const props = defineProps({
43
+ value: {
44
+ type: Number,
45
+ required: true,
46
+ },
47
+ disabled: {
48
+ type: Boolean,
49
+ },
50
+ concurrent: {
51
+ type: Boolean,
52
+ },
53
+ max: {
54
+ type: Number,
55
+ default: 0,
56
+ },
57
+ min: {
58
+ type: Number,
59
+ default: 0,
60
+ },
61
+ step: {
62
+ type: Number,
63
+ default: 1,
64
+ },
65
+ name: {
66
+ type: String,
67
+ default: '',
68
+ },
69
+ size: {
70
+ type: String,
71
+ default: '',
72
+ },
73
+ auth: {
74
+ type: String,
75
+ default: '',
76
+ },
77
+ throttle: {
78
+ type: Number,
79
+ default: 1000,
80
+ },
81
+ });
82
+
83
+ const { value, disabled, concurrent, max, min, step, name, size, auth, throttle } = toRefs(props);
84
+ const quantity = ref(value.value);
85
+ const focus = ref(false);
86
+
87
+ const digits = computed(() => {
88
+ const arr = String(step.value).split('.');
89
+ if (arr.length === 1) {
90
+ return 0;
91
+ }
92
+ return arr.at(-1).length;
93
+ });
94
+
95
+ watch(value, (newValue) => {
96
+ quantity.value = newValue;
97
+ });
98
+
99
+ let throttleTimeout;
100
+ const input = (inputValue) => {
101
+ if (auth.value) {
102
+ showDialog(document.querySelector(auth.value));
103
+ } else {
104
+ let newValue;
105
+
106
+ if (inputValue === '') {
107
+ quantity.value = inputValue;
108
+ newValue = min.value;
109
+ } else {
110
+ quantity.value = Number(inputValue);
111
+ if (quantity.value < min.value) {
112
+ newValue = min.value;
113
+ quantity.value = min.value;
114
+ } else if (quantity.value > max.value) {
115
+ newValue = max.value;
116
+ quantity.value = max.value;
117
+ } else {
118
+ newValue = quantity.value;
119
+ }
120
+ }
121
+ if (!concurrent.value) {
122
+ focus.value = true;
123
+ emit('disableOthers');
124
+ }
125
+ clearTimeout(throttleTimeout);
126
+ throttleTimeout = setTimeout(() => {
127
+ emit('input', newValue);
128
+ }, throttle.value);
129
+ }
130
+ };
131
+
132
+ const update = () => {
133
+ quantity.value = value.value;
134
+ if (!concurrent.value) {
135
+ focus.value = false;
136
+ emit('enableOthers');
137
+ }
138
+ };
139
+
140
+ defineExpose({
141
+ update,
142
+ });
143
+ </script>