vgapp 1.0.3 → 1.0.5

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.
@@ -137,12 +137,12 @@ class VGFilesBase extends BaseModule {
137
137
  Boolean(this._params?.replace) &&
138
138
  isSingle;
139
139
 
140
- if (shouldReplaceOnSingle) {
141
- this.clear(false);
142
- this.append(filesArray, true);
143
- this._revokeUrls();
144
- this._cleanupFakeInputs();
145
- this._cleanupErrors();
140
+ if (shouldReplaceOnSingle) {
141
+ this.clear(false);
142
+ this.append(filesArray, true);
143
+ this._revokeUrls();
144
+ this._cleanupFakeInputs();
145
+ this._cleanupErrors();
146
146
 
147
147
  this._files = this._filterFiles(filesArray);
148
148
  if (this._params.prepend) this._files.reverse();
@@ -209,6 +209,56 @@ class VGFilesBase extends BaseModule {
209
209
  return `${file.name}-${file.size}-${file.type}`;
210
210
  }
211
211
 
212
+ _getFileCustomData(file) {
213
+ const customData = file?.customData;
214
+ if (!customData || typeof customData !== 'object' || Array.isArray(customData)) return {};
215
+ return customData;
216
+ }
217
+
218
+ _toDataAttributeKey(key) {
219
+ if (!key) return '';
220
+
221
+ return String(key)
222
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
223
+ .replace(/[_\s]+/g, '-')
224
+ .replace(/[^a-zA-Z0-9-]/g, '')
225
+ .replace(/-+/g, '-')
226
+ .replace(/^-|-$/g, '')
227
+ .toLowerCase();
228
+ }
229
+
230
+ _toDataAttributeValue(value) {
231
+ if (value === undefined || value === null || value === '') return null;
232
+ if (typeof value === 'object') {
233
+ try {
234
+ return JSON.stringify(value);
235
+ } catch (e) {
236
+ return String(value);
237
+ }
238
+ }
239
+ return value;
240
+ }
241
+
242
+ _buildFileDataAttributes(file, baseAttrs = {}) {
243
+ const attrs = { ...baseAttrs };
244
+ const customData = this._getFileCustomData(file);
245
+
246
+ Object.entries(customData).forEach(([key, value]) => {
247
+ const attrKey = this._toDataAttributeKey(key);
248
+ if (!attrKey) return;
249
+
250
+ const attrName = `data-${attrKey}`;
251
+ if (Object.prototype.hasOwnProperty.call(attrs, attrName)) return;
252
+
253
+ const attrValue = this._toDataAttributeValue(value);
254
+ if (attrValue === null) return;
255
+
256
+ attrs[attrName] = attrValue;
257
+ });
258
+
259
+ return attrs;
260
+ }
261
+
212
262
  _filterFiles(files) {
213
263
  this._errors.clear();
214
264
  const { count, sizes, total } = this._params.limits;
@@ -378,14 +428,20 @@ class VGFilesBase extends BaseModule {
378
428
  }
379
429
  }
380
430
 
381
- let parts = [];
382
- $itemsTemplate.forEach(tmpl => {
383
- const part = this._renderTemplatePart(tmpl.element, file, null, { isDrop: true });
384
- if (part) parts.push(part);
385
- });
431
+ let parts = [];
432
+ $itemsTemplate.forEach(tmpl => {
433
+ const part = this._renderTemplatePart(tmpl.element, file, null, { isDrop: true });
434
+ if (part) parts.push(part);
435
+ });
386
436
 
387
437
  const $li = this._tpl.li(
388
- { 'data-name': file.name, 'data-size': file.size, 'data-id': file.id || '', class: 'file ' + classes.join(' ') }, parts
438
+ this._buildFileDataAttributes(file, {
439
+ 'data-name': file.name,
440
+ 'data-size': file.size ?? 0,
441
+ 'data-id': file.id || '',
442
+ class: 'file ' + classes.join(' ')
443
+ }),
444
+ parts
389
445
  );
390
446
 
391
447
  fragment.appendChild($li);
@@ -394,17 +450,17 @@ class VGFilesBase extends BaseModule {
394
450
  $list.innerHTML = '';
395
451
  $list.appendChild(fragment);
396
452
 
397
- const $message = Selectors.find(`.${this._getClass('drop-message')}`, this._nodes.drop);
398
- if ($message) {
399
- if (files.length) Classes.add($message, 'has-files');
400
- else Classes.remove($message, 'has-files');
401
-
402
- const isSingle = Number(this._params?.limits?.count) === 1;
403
- if (isSingle) {
404
- if (files.length) Classes.remove($message, 'show');
405
- else Classes.add($message, 'show');
406
- }
407
- }
453
+ const $message = Selectors.find(`.${this._getClass('drop-message')}`, this._nodes.drop);
454
+ if ($message) {
455
+ if (files.length) Classes.add($message, 'has-files');
456
+ else Classes.remove($message, 'has-files');
457
+
458
+ const isSingle = Number(this._params?.limits?.count) === 1;
459
+ if (isSingle) {
460
+ if (files.length) Classes.remove($message, 'show');
461
+ else Classes.add($message, 'show');
462
+ }
463
+ }
408
464
 
409
465
  this._nodes.drop.appendChild($list);
410
466
  Classes.add(this._nodes.drop, 'active');
@@ -435,14 +491,21 @@ class VGFilesBase extends BaseModule {
435
491
  if (this._params.detach) classes.push('with-remove')
436
492
  if (this._params.sortable.enabled) classes.push('with-sortable');
437
493
 
438
- let parts = [];
439
- $itemsTemplate.forEach(tmpl => {
440
- const part = this._renderTemplatePart(tmpl.element, file, i);
441
- if (part) parts.push(part);
442
- });
494
+ let parts = [];
495
+ $itemsTemplate.forEach(tmpl => {
496
+ const part = this._renderTemplatePart(tmpl.element, file, i);
497
+ if (part) parts.push(part);
498
+ });
443
499
 
444
500
  const $li = this._tpl.li(
445
- { 'data-name': file.name, 'data-size': file.size, 'data-type': file.type, 'data-id': file.id || '', class: 'file ' + classes.join(' ') + ' ' }, parts
501
+ this._buildFileDataAttributes(file, {
502
+ 'data-name': file.name,
503
+ 'data-size': file.size ?? 0,
504
+ 'data-type': file.type || '',
505
+ 'data-id': file.id || '',
506
+ class: 'file ' + classes.join(' ') + ' '
507
+ }),
508
+ parts
446
509
  );
447
510
  fragment.appendChild($li);
448
511
  });
@@ -451,38 +514,38 @@ class VGFilesBase extends BaseModule {
451
514
  Classes.add(this._nodes.info, 'show')
452
515
  }
453
516
 
454
- _renderTemplatePart(element, file, index = null, options = {}) {
455
- if (!element) return null;
456
- const { isDrop = false } = options;
457
-
458
- const classList = element?.classList;
459
-
460
- if (classList?.contains('file-image')) return this._renderUIImage(file);
461
- if (classList?.contains('file-info')) {
462
- if (isDrop) return null;
463
- return this._renderUIInfo(file, index);
464
- }
465
- if (classList?.contains('file-remove')) return this._wrapInFileCustom(this._renderUIDetach(file));
466
-
467
- const $part = element.cloneNode(true);
468
-
469
- this._replaceTemplateSlot($part, '.file-image', () => this._renderUIImage(file));
470
- this._replaceTemplateSlot($part, '.file-info', () => isDrop ? null : this._renderUIInfo(file, index));
471
- this._replaceTemplateSlot($part, '.file-remove', () => this._renderUIDetach(file));
472
-
473
- return this._wrapInFileCustom($part);
474
- }
475
-
476
- _wrapInFileCustom(node) {
477
- if (!node) return null;
478
- if (node.classList?.contains('file-custom')) return node;
479
-
480
- const wrapper = document.createElement('div');
481
- wrapper.className = 'file-custom';
482
- wrapper.appendChild(node);
483
-
484
- return wrapper;
485
- }
517
+ _renderTemplatePart(element, file, index = null, options = {}) {
518
+ if (!element) return null;
519
+ const { isDrop = false } = options;
520
+
521
+ const classList = element?.classList;
522
+
523
+ if (classList?.contains('file-image')) return this._renderUIImage(file);
524
+ if (classList?.contains('file-info')) {
525
+ if (isDrop) return null;
526
+ return this._renderUIInfo(file, index);
527
+ }
528
+ if (classList?.contains('file-remove')) return this._wrapInFileCustom(this._renderUIDetach(file));
529
+
530
+ const $part = element.cloneNode(true);
531
+
532
+ this._replaceTemplateSlot($part, '.file-image', () => this._renderUIImage(file));
533
+ this._replaceTemplateSlot($part, '.file-info', () => isDrop ? null : this._renderUIInfo(file, index));
534
+ this._replaceTemplateSlot($part, '.file-remove', () => this._renderUIDetach(file));
535
+
536
+ return this._wrapInFileCustom($part);
537
+ }
538
+
539
+ _wrapInFileCustom(node) {
540
+ if (!node) return null;
541
+ if (node.classList?.contains('file-custom')) return node;
542
+
543
+ const wrapper = document.createElement('div');
544
+ wrapper.className = 'file-custom';
545
+ wrapper.appendChild(node);
546
+
547
+ return wrapper;
548
+ }
486
549
 
487
550
  _replaceTemplateSlot(container, selector, renderer) {
488
551
  const isMatchSelf = container?.matches && container.matches(selector);
@@ -586,14 +649,14 @@ class VGFilesBase extends BaseModule {
586
649
  Selectors.findAll('[data-vg-files="generated"]', this._element).forEach(el => el.remove());
587
650
  }
588
651
 
589
- clear(resetInput = true) {
590
- this._revokeUrls();
591
- if (resetInput) {
592
- this._resetFileInput();
593
- }
594
- this._cleanupFakeInputs();
595
- this._cleanupErrors();
596
- this._files = [];
652
+ clear(resetInput = true) {
653
+ this._revokeUrls();
654
+ if (resetInput) {
655
+ this._resetFileInput();
656
+ }
657
+ this._cleanupFakeInputs();
658
+ this._cleanupErrors();
659
+ this._files = [];
597
660
 
598
661
  if (this._nodes.info) {
599
662
  Classes.remove(this._nodes.info, 'show');
@@ -604,15 +667,15 @@ class VGFilesBase extends BaseModule {
604
667
  const $list = Selectors.find(`.${this._getClass('drop-list')}`, this._element);
605
668
  if ($list) $list.innerHTML = '';
606
669
 
607
- const $message = Selectors.find(`.${this._getClass('drop-message')}`, this._element);
608
- if ($message) {
609
- Classes.remove($message, 'has-files');
610
-
611
- const isSingle = Number(this._params?.limits?.count) === 1;
612
- if (isSingle) {
613
- Classes.add($message, 'show');
614
- }
615
- }
670
+ const $message = Selectors.find(`.${this._getClass('drop-message')}`, this._element);
671
+ if ($message) {
672
+ Classes.remove($message, 'has-files');
673
+
674
+ const isSingle = Number(this._params?.limits?.count) === 1;
675
+ if (isSingle) {
676
+ Classes.add($message, 'show');
677
+ }
678
+ }
616
679
 
617
680
  Classes.remove(this._nodes.drop, 'active');
618
681
  }
@@ -1,7 +1,7 @@
1
1
  import { isElement, normalizeData } from "../../../utils/js/functions";
2
2
  import Params from "../../../utils/js/components/params";
3
3
  import Selectors from "../../../utils/js/dom/selectors";
4
- import { Classes, Manipulator } from "../../../utils/js/dom/manipulator";
4
+ import { Manipulator } from "../../../utils/js/dom/manipulator";
5
5
 
6
6
  class VGFilesTemplateRender {
7
7
  constructor(vgFilesInstance, element, params = {}) {
@@ -38,34 +38,105 @@ class VGFilesTemplateRender {
38
38
  const $items = Array.from($list.children).filter(li => li.tagName === 'LI');
39
39
  if ($items.length === 0) return false;
40
40
 
41
- // Сохраняем шаблон только один раз
42
41
  this._setTemplateInBuffer($items);
43
-
44
42
  if (!this.bufferTemplate) return false;
45
43
 
46
- // Парсим данные файлов
47
44
  this.parsedFiles = $items
48
45
  .map(li => {
49
46
  const rawData = Manipulator.get(li, 'data-file');
50
47
  if (!rawData) return null;
51
48
 
52
- const dataFile = normalizeData(rawData);
53
- const requiredKeys = ['id', 'name', 'size', 'type', 'src'];
54
- const isValid = requiredKeys.every(key => dataFile.hasOwnProperty(key));
55
-
56
- return isValid ? dataFile : null;
49
+ return this._parseDataFile(rawData);
57
50
  })
58
- .filter(Boolean); // Убираем null
51
+ .filter(Boolean);
59
52
 
60
53
  return true;
61
54
  }
62
55
 
56
+ _parseDataFile(rawData) {
57
+ const dataFile = normalizeData(rawData);
58
+ if (!dataFile || typeof dataFile !== 'object' || Array.isArray(dataFile)) return null;
59
+
60
+ const binaryMeta = this._extractBinaryMeta(dataFile);
61
+ const name = this._toStringOrNull(dataFile.name);
62
+ const id = this._toStringOrNull(dataFile.id);
63
+ const src = this._toStringOrNull(dataFile.src);
64
+ const type = this._toStringOrNull(dataFile.type) || binaryMeta.type || 'application/octet-stream';
65
+ const size = this._toNumberOrNull(dataFile.size) ?? binaryMeta.size ?? 0;
66
+ const lastModified = this._toNumberOrNull(dataFile.lastModified ?? dataFile['last-modified']) ?? binaryMeta.lastModified ?? Date.now();
67
+
68
+ const result = {
69
+ id,
70
+ name,
71
+ size,
72
+ type,
73
+ src,
74
+ lastModified
75
+ };
76
+
77
+ if (dataFile.image !== undefined && dataFile.image !== null && dataFile.image !== '') {
78
+ result.image = dataFile.image;
79
+ }
80
+
81
+ const requiredKeys = ['id', 'name', 'size', 'type', 'src'];
82
+ const isValid = requiredKeys.every((key) => {
83
+ const value = result[key];
84
+ if (key === 'size') return Number.isFinite(value);
85
+ return value !== undefined && value !== null && value !== '';
86
+ });
87
+
88
+ if (!isValid) return null;
89
+
90
+ const customData = {};
91
+ const reserved = new Set(['id', 'name', 'size', 'type', 'src', 'image', 'lastModified', 'last-modified']);
92
+
93
+ Object.entries(dataFile).forEach(([key, value]) => {
94
+ if (reserved.has(key)) return;
95
+ if (value === undefined || value === null || value === '') return;
96
+ customData[key] = value;
97
+ });
98
+
99
+ if (Object.keys(customData).length) {
100
+ result.customData = customData;
101
+ }
102
+
103
+ return result;
104
+ }
105
+
106
+ _extractBinaryMeta(dataFile) {
107
+ const binary = dataFile.file || dataFile.blob || dataFile.originFile || null;
108
+ if (!(binary instanceof Blob)) return {};
109
+
110
+ const size = this._toNumberOrNull(binary.size);
111
+ const type = this._toStringOrNull(binary.type);
112
+ const lastModified = (binary instanceof File)
113
+ ? this._toNumberOrNull(binary.lastModified)
114
+ : null;
115
+
116
+ return {
117
+ size: size ?? null,
118
+ type: type || null,
119
+ lastModified: lastModified ?? null
120
+ };
121
+ }
122
+
123
+ _toStringOrNull(value) {
124
+ if (value === undefined || value === null) return null;
125
+ const normalized = String(value).trim();
126
+ return normalized ? normalized : null;
127
+ }
128
+
129
+ _toNumberOrNull(value) {
130
+ if (value === undefined || value === null || value === '') return null;
131
+ const normalized = Number(value);
132
+ return Number.isFinite(normalized) ? normalized : null;
133
+ }
134
+
63
135
  _setTemplateInBuffer($items) {
64
136
  if (this.bufferTemplate || $items.length === 0) return;
65
137
 
66
138
  const firstItem = $items[0];
67
139
 
68
- // Если нет data-file — шаблон извлекается и элемент удаляется
69
140
  if (!Manipulator.has(firstItem, 'data-file')) {
70
141
  this.bufferTemplate = firstItem.outerHTML;
71
142
  firstItem.remove();
@@ -80,4 +151,4 @@ class VGFilesTemplateRender {
80
151
  }
81
152
  }
82
153
 
83
- export default VGFilesTemplateRender;
154
+ export default VGFilesTemplateRender;
@@ -41,21 +41,21 @@ class VGFiles extends VGFilesBase {
41
41
  types: [],
42
42
  ajax: false,
43
43
  prepend: true,
44
- replace: true,
45
- rename: false,
46
- smartdrop: false,
47
- uploads: {
48
- mode: 'sequential',
49
- route: '',
44
+ replace: true,
45
+ rename: false,
46
+ smartdrop: false,
47
+ uploads: {
48
+ mode: 'sequential',
49
+ route: '',
50
50
  maxParallel: 3,
51
51
  maxConcurrent: 1,
52
52
  retryAttempts: 1,
53
53
  retryDelay: 1000,
54
54
  },
55
- removes: {
56
- all: { route: '', alert: true, toast: true },
57
- single: { route: '', alert: true, toast: true }
58
- },
55
+ removes: {
56
+ all: { route: '', alert: true, toast: true, confirm: null },
57
+ single: { route: '', alert: true, toast: true, confirm: null }
58
+ },
59
59
  sortable: {
60
60
  enabled: false,
61
61
  route: '',
@@ -106,67 +106,91 @@ class VGFiles extends VGFilesBase {
106
106
  if (this.isRenderNonInit) return;
107
107
  this.isRenderNonInit = this._render.init();
108
108
 
109
- if (this._render.parsedFiles.length) {
110
- this._addExternalFiles(this._render.parsedFiles);
109
+ const parsedFiles = this._render.parsedFiles;
110
+
111
+ if (parsedFiles.length) {
112
+ this._addExternalFiles(parsedFiles);
111
113
  }
112
114
 
113
- if (this._params.allowed && !this._params.ajax) this._params.detach = false;
114
- if (this._nodes.drop) {
115
- this._params.image = true;
116
- this._params.detach = true;
117
- this._setDropActiveText();
118
-
119
- VGFilesDroppable.getOrCreateInstance(this._nodes.drop, this._params).init();
120
- }
115
+ if (this._params.allowed && !this._params.ajax) this._params.detach = false;
116
+ if (this._nodes.drop) {
117
+ this._params.image = true;
118
+ this._params.detach = true;
119
+ this._setDropActiveText();
121
120
 
122
- this._addEventListenerExtended();
123
- this._renderStat();
124
- this._triggerCallback('onInit', { element: this._element });
125
- }
126
-
127
- _setDropActiveText() {
128
- if (!this._nodes.drop) return;
129
-
130
- const messages = lang_messages(this._params.lang, NAME) || {};
131
- const activeText = (this._nodes.drop.getAttribute('data-drop-active-text') || '').trim() || messages['drop-active'] || 'Release to upload';
132
- this._nodes.drop.setAttribute('data-drop-active-text', activeText);
133
-
134
- const title = Selectors.find('.vg-files-drop-message .title', this._nodes.drop);
135
- if (!title) return;
136
-
137
- const originalText = (title.getAttribute('data-drop-original-text') || '').trim() || (title.textContent || '').trim();
138
- title.setAttribute('data-drop-original-text', originalText);
139
- }
121
+ VGFilesDroppable.getOrCreateInstance(this._nodes.drop, this._params).init();
122
+ }
123
+
124
+ this._addEventListenerExtended();
125
+ this._renderStat();
126
+ this._triggerCallback('onInit', { element: this._element, files: parsedFiles || [] });
127
+ }
128
+
129
+ _setDropActiveText() {
130
+ if (!this._nodes.drop) return;
131
+
132
+ const messages = lang_messages(this._params.lang, NAME) || {};
133
+ const activeText = (this._nodes.drop.getAttribute('data-drop-active-text') || '').trim() || messages['drop-active'] || 'Release to upload';
134
+ this._nodes.drop.setAttribute('data-drop-active-text', activeText);
135
+
136
+ const title = Selectors.find('.vg-files-drop-message .title', this._nodes.drop);
137
+ if (!title) return;
138
+
139
+ const originalText = (title.getAttribute('data-drop-original-text') || '').trim() || (title.textContent || '').trim();
140
+ title.setAttribute('data-drop-original-text', originalText);
141
+ }
140
142
 
141
143
  _addExternalFiles(files) {
142
144
  if (!Array.isArray(files) || !files.length) return;
143
145
 
144
146
  files.forEach(fileData => {
145
- const file = new File([""], fileData.name, {
146
- type: fileData.type || "application/octet-stream",
147
- lastModified: fileData.lastModified || Date.now()
148
- });
147
+ if (!fileData?.name) return;
149
148
 
150
- // Добавляем ID, если он есть
151
- Object.defineProperty(file, 'id', {
152
- value: fileData.id,
153
- writable: true,
154
- enumerable: true
155
- });
149
+ const fileOptions = {};
150
+ if (typeof fileData.type === 'string') fileOptions.type = fileData.type;
151
+ if (Number.isFinite(fileData.lastModified)) fileOptions.lastModified = fileData.lastModified;
156
152
 
157
- // Добавляем Size, если он есть
158
- Object.defineProperty(file, 'size', {
159
- value: fileData.size,
160
- writable: true,
161
- enumerable: true
162
- });
153
+ const file = new File([""], fileData.name, fileOptions);
163
154
 
164
- // Добавляем Src, если он есть
165
- Object.defineProperty(file, 'src', {
166
- value: fileData.src,
167
- writable: true,
168
- enumerable: true
169
- });
155
+ if (fileData.id !== undefined && fileData.id !== null && fileData.id !== '') {
156
+ Object.defineProperty(file, 'id', {
157
+ value: fileData.id,
158
+ writable: true,
159
+ enumerable: true
160
+ });
161
+ }
162
+
163
+ if (Number.isFinite(fileData.size)) {
164
+ Object.defineProperty(file, 'size', {
165
+ value: fileData.size,
166
+ writable: true,
167
+ enumerable: true
168
+ });
169
+ }
170
+
171
+ if (fileData.src !== undefined && fileData.src !== null && fileData.src !== '') {
172
+ Object.defineProperty(file, 'src', {
173
+ value: fileData.src,
174
+ writable: true,
175
+ enumerable: true
176
+ });
177
+ }
178
+
179
+ if (fileData.image !== undefined && fileData.image !== null && fileData.image !== '') {
180
+ Object.defineProperty(file, 'image', {
181
+ value: fileData.image,
182
+ writable: true,
183
+ enumerable: true
184
+ });
185
+ }
186
+
187
+ if (fileData.customData && typeof fileData.customData === 'object' && !Array.isArray(fileData.customData)) {
188
+ Object.defineProperty(file, 'customData', {
189
+ value: fileData.customData,
190
+ writable: true,
191
+ enumerable: true
192
+ });
193
+ }
170
194
 
171
195
  const fileKey = this._getFileKey(file);
172
196
 
@@ -207,32 +231,32 @@ class VGFiles extends VGFilesBase {
207
231
  });
208
232
  }
209
233
 
210
- _handleChange(e) {
211
- const input = e?.target;
212
- const inputFiles = this._snapshotInputFiles(input);
213
- this.change(input);
214
-
215
- if (this._params.ajax) this.uploadAll(this._files);
216
-
217
- const payload = {
218
- files: this._files,
219
- input: e?.target || e?.src || '',
220
- inputFiles
221
- };
222
-
223
- this._triggerCallback('onChange', payload);
224
- this._triggerEvent('change', {
225
- files: this._files,
226
- input: payload.input,
227
- inputFiles: payload.inputFiles
228
- });
229
- }
230
-
231
- _snapshotInputFiles(input) {
232
- const files = input?.files;
233
- if (!files?.length) return [];
234
- return Array.from(files);
235
- }
234
+ _handleChange(e) {
235
+ const input = e?.target;
236
+ const inputFiles = this._snapshotInputFiles(input);
237
+ this.change(input);
238
+
239
+ if (this._params.ajax) this.uploadAll(this._files);
240
+
241
+ const payload = {
242
+ files: this._files,
243
+ input: e?.target || e?.src || '',
244
+ inputFiles
245
+ };
246
+
247
+ this._triggerCallback('onChange', payload);
248
+ this._triggerEvent('change', {
249
+ files: this._files,
250
+ input: payload.input,
251
+ inputFiles: payload.inputFiles
252
+ });
253
+ }
254
+
255
+ _snapshotInputFiles(input) {
256
+ const files = input?.files;
257
+ if (!files?.length) return [];
258
+ return Array.from(files);
259
+ }
236
260
 
237
261
  async uploadAll(files) {
238
262
  if (!this._params.ajax || !this._params.uploads.route) return;
@@ -501,8 +525,8 @@ class VGFiles extends VGFilesBase {
501
525
  });
502
526
  }
503
527
 
504
- reload(button) {
505
- if (!this._params.ajax || !this._params.uploads.route) return;
528
+ reload(button) {
529
+ if (!this._params.ajax || !this._params.uploads.route) return;
506
530
 
507
531
  const dataButton = Manipulator.get(button, 'data');
508
532
  const fileData = {
@@ -532,10 +556,85 @@ class VGFiles extends VGFilesBase {
532
556
  this._triggerCallback('onReload', payload);
533
557
  this._triggerEvent('reload', payload);
534
558
 
535
- this.upload(fileToRetry);
536
- }
537
-
538
- removeFile(button) {
559
+ this.upload(fileToRetry);
560
+ }
561
+
562
+ _runAjaxRequest(paramsAjax, callback) {
563
+ const previousAjax = this._params.ajax;
564
+ this._params.ajax = paramsAjax;
565
+
566
+ this._route((status, data) => {
567
+ this._params.ajax = previousAjax;
568
+ callback(status, data);
569
+ });
570
+ }
571
+
572
+ _normalizeConfirmResult(result) {
573
+ if (result === true) return { accepted: true, data: null };
574
+ if (result === false || result == null) return { accepted: false, data: null };
575
+
576
+ if (typeof result === 'object') {
577
+ if (typeof result.accepted === 'boolean') {
578
+ return { accepted: result.accepted, data: result.data ?? null };
579
+ }
580
+ if (Object.prototype.hasOwnProperty.call(result, 'data')) {
581
+ return { accepted: true, data: result.data ?? null };
582
+ }
583
+ }
584
+
585
+ return { accepted: Boolean(result), data: null };
586
+ }
587
+
588
+ _runDefaultRemoveConfirm(trigger, params) {
589
+ return new Promise((resolve) => {
590
+ VGAlert.confirm(trigger, {
591
+ lang: params.lang,
592
+ ajax: params.ajax,
593
+ buttons: params.buttons,
594
+ message: params.message
595
+ });
596
+
597
+ EventHandler.one(trigger, 'vg.alert.accept', (event) => {
598
+ resolve({ accepted: true, data: event?.vgalert?.data ?? null });
599
+ });
600
+
601
+ EventHandler.one(trigger, 'vg.alert.reject', () => {
602
+ resolve({ accepted: false, data: null });
603
+ });
604
+ });
605
+ }
606
+
607
+ _confirmRemove(type, trigger, ajax, message) {
608
+ const buttons = {
609
+ agree: {
610
+ text: lang_buttons(this._params.lang, NAME)['agree'],
611
+ class: ["btn-danger"],
612
+ },
613
+ cancel: {
614
+ text: lang_buttons(this._params.lang, NAME)['cancel'],
615
+ class: ["btn-outline-danger"],
616
+ },
617
+ };
618
+
619
+ const confirmParams = {
620
+ type,
621
+ trigger,
622
+ lang: this._params.lang,
623
+ ajax,
624
+ buttons,
625
+ message
626
+ };
627
+
628
+ const customConfirm = this._params?.removes?.[type]?.confirm;
629
+ if (typeof customConfirm === 'function') {
630
+ return Promise.resolve(customConfirm(confirmParams, this))
631
+ .then((result) => this._normalizeConfirmResult(result));
632
+ }
633
+
634
+ return this._runDefaultRemoveConfirm(trigger, confirmParams);
635
+ }
636
+
637
+ removeFile(button) {
539
638
  const name = normalizeData(Manipulator.get(button, 'data-name'));
540
639
  const size = normalizeData(Manipulator.get(button, 'data-size'));
541
640
  const id = normalizeData(Manipulator.get(button, 'data-id'));
@@ -569,7 +668,9 @@ class VGFiles extends VGFilesBase {
569
668
  if (this._params.ajax && this._params.removes.single.route) {
570
669
  if (!id) return;
571
670
 
572
- const route = this._params.removes.single.route + '/' + encodeURIComponent(id);
671
+ const routeBase = this._params.removes.single.route;
672
+ const routeSeparator = routeBase.includes('?') ? '&' : '?';
673
+ const route = `${routeBase}${routeSeparator}id=${encodeURIComponent(id)}`;
573
674
  const paramsAjax = {
574
675
  route: route,
575
676
  method: 'delete'
@@ -590,35 +691,31 @@ class VGFiles extends VGFilesBase {
590
691
  }
591
692
  };
592
693
 
593
- if (this._params.removes.single.alert) {
594
- VGAlert.confirm(button, {
595
- lang: this._params.lang,
596
- ajax: paramsAjax,
597
- buttons: {
598
- agree: {
599
- text: lang_buttons(this._params.lang, NAME)['agree'],
600
- class: ["btn-danger"],
601
- },
602
- cancel: {
603
- text: lang_buttons(this._params.lang, NAME)['cancel'],
604
- class: ["btn-outline-danger"],
605
- },
606
- },
607
- message: {
608
- title: lang_messages(this._params.lang, NAME)['title'],
609
- description: lang_messages(this._params.lang, NAME)['description']
610
- }
611
- });
612
-
613
- EventHandler.one(button, 'vg.alert.accept', (event) => {
614
- _completeRemoveFile(event.vgalert.data);
615
- });
616
- } else {
617
- this._params.ajax = paramsAjax;
618
- this._route((status, data) => {
619
- _completeRemoveFile(data);
620
- });
621
- }
694
+ if (this._params.removes.single.alert) {
695
+ const message = {
696
+ title: lang_messages(this._params.lang, NAME)['title'],
697
+ description: lang_messages(this._params.lang, NAME)['description']
698
+ };
699
+
700
+ this._confirmRemove('single', button, paramsAjax, message)
701
+ .then((result) => {
702
+ if (!result.accepted) return;
703
+
704
+ if (result.data) {
705
+ _completeRemoveFile(result.data);
706
+ return;
707
+ }
708
+
709
+ this._runAjaxRequest(paramsAjax, (status, data) => {
710
+ _completeRemoveFile(data);
711
+ });
712
+ })
713
+ .catch(() => {});
714
+ } else {
715
+ this._runAjaxRequest(paramsAjax, (status, data) => {
716
+ _completeRemoveFile(data);
717
+ });
718
+ }
622
719
  } else {
623
720
  this._files = this._files.filter(f => !(f.name === name && f.size === size));
624
721
  this._updateStatsAfterRemove();
@@ -682,15 +779,15 @@ class VGFiles extends VGFilesBase {
682
779
 
683
780
  return this._tpl.button([
684
781
  this._tpl.i({}, icon, { isHTML: true })
685
- ], 'button', {
782
+ ], 'button', this._buildFileDataAttributes(file, {
686
783
  type: 'button',
687
784
  [action]: 'file',
688
785
  'data-name': file.name,
689
- 'data-size': file.size,
690
- 'data-type': file.type,
691
- 'data-last-modified': file.lastModified,
786
+ 'data-size': file.size ?? 0,
787
+ 'data-type': file.type || '',
788
+ 'data-last-modified': file.lastModified || '',
692
789
  'data-id': file.id || ''
693
- });
790
+ }));
694
791
  }
695
792
 
696
793
  _renderStat() {
@@ -835,37 +932,37 @@ EventHandler.on(document, `click.${NAME_KEY}.data.api`, SELECTOR_DATA_DISMISS_AL
835
932
  instance.clear(true, true);
836
933
  };
837
934
 
838
- if (instance._params.removes.all.alert) {
839
- VGAlert.confirm(e.target, {
840
- lang: instance._params.lang,
841
- ajax: paramsAjax,
842
- buttons: {
843
- agree: {
844
- text: lang_buttons(instance._params.lang, NAME)['agree'],
845
- class: ["btn-danger"],
846
- },
847
- cancel: {
848
- text: lang_buttons(instance._params.lang, NAME)['cancel'],
849
- class: ["btn-outline-danger"],
850
- },
851
- },
852
- message: {
853
- title: lang_messages(instance._params.lang, NAME)['title'],
854
- description: lang_messages(instance._params.lang, NAME)['descriptions']
855
- }
856
- });
857
-
858
- EventHandler.one(e.target, 'vg.alert.accept', (event) => {
859
- if (instance._params.removes.all.toast) {
860
- VGToast.run(event.vgalert.data?.response?.message);
861
- }
862
- _completeClearAll();
863
- });
864
- } else {
865
- instance._route(paramsAjax, (status, data) => {
866
- if (instance._params.removes.all.toast && data?.response?.message) {
867
- VGToast.run(data.response.message);
868
- }
935
+ if (instance._params.removes.all.alert) {
936
+ const message = {
937
+ title: lang_messages(instance._params.lang, NAME)['title'],
938
+ description: lang_messages(instance._params.lang, NAME)['descriptions']
939
+ };
940
+
941
+ instance._confirmRemove('all', e.target, paramsAjax, message)
942
+ .then((result) => {
943
+ if (!result.accepted) return;
944
+
945
+ if (result.data) {
946
+ if (instance._params.removes.all.toast) {
947
+ VGToast.run(result.data?.response?.message);
948
+ }
949
+ _completeClearAll();
950
+ return;
951
+ }
952
+
953
+ instance._runAjaxRequest(paramsAjax, (status, data) => {
954
+ if (instance._params.removes.all.toast && data?.response?.message) {
955
+ VGToast.run(data.response.message);
956
+ }
957
+ _completeClearAll();
958
+ });
959
+ })
960
+ .catch(() => {});
961
+ } else {
962
+ instance._runAjaxRequest(paramsAjax, (status, data) => {
963
+ if (instance._params.removes.all.toast && data?.response?.message) {
964
+ VGToast.run(data.response.message);
965
+ }
869
966
  _completeClearAll();
870
967
  });
871
968
  }
@@ -874,4 +971,4 @@ EventHandler.on(document, `click.${NAME_KEY}.data.api`, SELECTOR_DATA_DISMISS_AL
874
971
  }
875
972
  });
876
973
 
877
- export default VGFiles;
974
+ export default VGFiles;
@@ -62,9 +62,13 @@ const files = new VGFiles(document.querySelector('.vg-files'), {
62
62
  | `uploads.retryAttempts` | `number` | `1` | Кол-во попыток повтора при ошибке |
63
63
  | `uploads.retryDelay` | `number` | `1000` | Задержка между попытками (мс) |
64
64
  | `removes.single.route` | `string` | `''` | URL для удаления одного файла |
65
- | `removes.all.route` | `string` | `''` | URL для удаления всех файлов |
66
- | `removes.single.alert` | `boolean` | `true` | Показывать подтверждение при удалении |
67
- | `removes.single.toast` | `boolean` | `true` | Показывать уведомление после удаления |
65
+ | `removes.all.route` | `string` | `''` | URL для удаления всех файлов |
66
+ | `removes.all.alert` | `boolean` | `true` | Показывать подтверждение при удалении всех файлов |
67
+ | `removes.all.toast` | `boolean` | `true` | Показывать уведомление после удаления всех файлов |
68
+ | `removes.all.confirm` | `function \| null` | `null` | Кастомный confirm-обработчик для удаления всех файлов |
69
+ | `removes.single.alert` | `boolean` | `true` | Показывать подтверждение при удалении |
70
+ | `removes.single.toast` | `boolean` | `true` | Показывать уведомление после удаления |
71
+ | `removes.single.confirm` | `function \| null` | `null` | Кастомный confirm-обработчик для удаления одного файла |
68
72
  | `sortable.enabled` | `boolean` | `false` | Включить сортировку |
69
73
  | `sortable.route` | `string` | `''` | URL для сохранения порядка файлов |
70
74
  | `callbacks` | `object` | `null` | Колбэки на события (см. ниже) |
@@ -116,7 +120,34 @@ const files = new VGFiles(document.querySelector('.vg-files'), {
116
120
  - **Удаление одного файла**: кнопка с `data-vg-dismiss="file"` → вызов `removeFile()`.
117
121
  - **Очистка всех**: кнопка с `data-vg-dismiss="vg-files"` → `clear()`.
118
122
 
119
- Поддержка подтверждения через `VGAlert` и уведомлений через `VGToast`.
123
+ Поддержка подтверждения через `VGAlert` и уведомлений через `VGToast`.
124
+
125
+ ### Кастомный confirm для удаления
126
+
127
+ Если задан `removes.single.confirm` или `removes.all.confirm`, модуль вызовет вашу функцию вместо дефолтного `VGAlert`.
128
+
129
+ ```js
130
+ new VGFiles(document.querySelector('.vg-files'), {
131
+ ajax: true,
132
+ removes: {
133
+ single: {
134
+ route: '/api/file/remove',
135
+ alert: true,
136
+ confirm: async ({ message }) => {
137
+ const accepted = window.confirm(`${message.title}\n${message.description}`);
138
+ return { accepted };
139
+ }
140
+ }
141
+ }
142
+ });
143
+ ```
144
+
145
+ Контракт функции confirm:
146
+ - Вход: объект `{ type, trigger, lang, ajax, buttons, message }`.
147
+ - Выход:
148
+ `true` или `{ accepted: true }` — подтвердить;
149
+ `false` или `{ accepted: false }` — отменить;
150
+ `{ accepted: true, data }` — подтвердить и передать готовый ответ удаления (если запрос уже выполнен внутри confirm).
120
151
 
121
152
  ---
122
153
 
@@ -52,6 +52,10 @@ class VGNav extends BaseModule {
52
52
  breakpoint: 'lg',
53
53
  placement: 'horizontal',
54
54
  hover: true,
55
+ hoversmoothfirstlevel: {
56
+ enable: false,
57
+ horizontalOnly: true
58
+ },
55
59
  animation: {
56
60
  enable: true,
57
61
  timeout: 700
@@ -98,6 +102,7 @@ class VGNav extends BaseModule {
98
102
  }
99
103
 
100
104
  this._openDrops = new Map();
105
+ this._pointerPosition = null;
101
106
  this._handleScroll = this._handleScroll.bind(this);
102
107
  this._handleResize = this._handleResize.bind(this);
103
108
  }
@@ -333,6 +338,50 @@ class VGNav extends BaseModule {
333
338
  return vertInView && horInView;
334
339
  }
335
340
 
341
+ _updatePointerPosition(event) {
342
+ if (!event || typeof event.clientX !== 'number' || typeof event.clientY !== 'number') return;
343
+ this._pointerPosition = {
344
+ x: event.clientX,
345
+ y: event.clientY
346
+ };
347
+ }
348
+
349
+ _isHorizontalPointerMove(event) {
350
+ if (!this._pointerPosition || !event) return false;
351
+
352
+ const dx = event.clientX - this._pointerPosition.x;
353
+ const dy = event.clientY - this._pointerPosition.y;
354
+
355
+ return Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 0;
356
+ }
357
+
358
+ _isFirstLevelDropdown(drop) {
359
+ return !!drop && !drop.closest('.dropdown-content');
360
+ }
361
+
362
+ _hasDirectDropdownContent(drop) {
363
+ if (!drop || !drop.children) return false;
364
+ return [...drop.children].some((child) => child.classList && child.classList.contains('dropdown-content'));
365
+ }
366
+
367
+ _isAdjacentDropdown(drop, relatedDrop) {
368
+ if (!drop || !relatedDrop) return false;
369
+ if (drop.parentElement !== relatedDrop.parentElement) return false;
370
+ return drop.previousElementSibling === relatedDrop || drop.nextElementSibling === relatedDrop;
371
+ }
372
+
373
+ _canSmoothSwitchFirstLevel(event, currentDrop, relatedDrop) {
374
+ const smoothParams = this._params.hoversmoothfirstlevel || {};
375
+ if (!smoothParams.enable) return false;
376
+ if (!currentDrop || !relatedDrop || currentDrop === relatedDrop) return false;
377
+ if (!this._isFirstLevelDropdown(currentDrop) || !this._isFirstLevelDropdown(relatedDrop)) return false;
378
+ if (!this._hasDirectDropdownContent(currentDrop) || !this._hasDirectDropdownContent(relatedDrop)) return false;
379
+ if (!this._isAdjacentDropdown(currentDrop, relatedDrop)) return false;
380
+ if (smoothParams.horizontalOnly && !this._isHorizontalPointerMove(event)) return false;
381
+
382
+ return true;
383
+ }
384
+
336
385
  static init(element, params = {}) {
337
386
  const instance = VGNav.getOrCreateInstance(element, params);
338
387
  instance.build();
@@ -340,12 +389,15 @@ class VGNav extends BaseModule {
340
389
  let drops = Selectors.findAll('.dropdown', instance.navigation);
341
390
 
342
391
  if (instance._params.hover && !isMobileDevice()) {
392
+ EventHandler.on(instance.navigation, `mousemove.${NAME_KEY}.data.api`, function (event) {
393
+ instance._updatePointerPosition(event);
394
+ });
395
+
343
396
  [...drops].forEach(function (el) {
344
397
  let currentElem = null;
345
398
 
346
399
  EventHandler.on(el, EVENT_MOUSEOVER_DATA_API, function (event) {
347
400
  if (currentElem) return;
348
- VGNav.hideOpenDrops(event);
349
401
 
350
402
  let target = event.target.closest('.dropdown');
351
403
  if (!target) return;
@@ -353,17 +405,31 @@ class VGNav extends BaseModule {
353
405
  if (!instance.navigation.contains(target)) return;
354
406
  currentElem = target;
355
407
 
408
+ const previousDrop = event.relatedTarget?.closest('.dropdown');
409
+ const useSmoothSwitch = instance._canSmoothSwitchFirstLevel(event, target, previousDrop);
410
+
411
+ if (!useSmoothSwitch) {
412
+ VGNav.hideOpenDrops(event);
413
+ }
414
+
356
415
  let relatedTarget = {
357
416
  relatedTarget: target
358
417
  };
359
418
 
360
419
  instance.show(relatedTarget);
420
+
421
+ if (useSmoothSwitch && previousDrop && previousDrop.classList.contains(CLASS_NAME_ACTIVE)) {
422
+ instance.hide({ relatedTarget: previousDrop });
423
+ }
424
+
425
+ instance._updatePointerPosition(event);
361
426
  });
362
427
 
363
428
  EventHandler.on(el, EVENT_MOUSEOUT_DATA_API, function (event) {
364
429
  if (!currentElem) return;
365
430
 
366
- let relatedTarget = event.relatedTarget?.closest('.dropdown'),
431
+ let nextDrop = event.relatedTarget?.closest('.dropdown'),
432
+ relatedTarget = nextDrop,
367
433
  elm = currentElem;
368
434
 
369
435
  while (relatedTarget) {
@@ -371,8 +437,15 @@ class VGNav extends BaseModule {
371
437
  relatedTarget = relatedTarget.parentNode;
372
438
  }
373
439
 
440
+ if (instance._canSmoothSwitchFirstLevel(event, elm, nextDrop)) {
441
+ currentElem = null;
442
+ instance._updatePointerPosition(event);
443
+ return;
444
+ }
445
+
374
446
  currentElem = null;
375
447
  instance.hide({ relatedTarget: relatedTarget, elm: elm });
448
+ instance._updatePointerPosition(event);
376
449
  });
377
450
  });
378
451
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vgapp",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "",
5
5
  "author": {
6
6
  "name": "Vegas Studio",