vgapp 1.4.0 → 1.4.2

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/README.md CHANGED
@@ -142,10 +142,38 @@ app
142
142
  - `VGSidebar` — боковые панели с управлением открытием/закрытием.
143
143
  - `VGSpy` — scroll‑spy для подсветки активных ссылок по секциям.
144
144
  - `VGTabs` — вкладки с переключением контента и поддержкой URL/hash.
145
- - `VGToast` — всплывающие уведомления со стеком и автозакрытием.
146
- - `VGTooltip` — tooltip/popover подсказки с Data API, автопозиционированием и закрытием по `Esc`/клику вне элемента.
147
-
148
- ## Authors
145
+ - `VGToast` — всплывающие уведомления со стеком и автозакрытием.
146
+ - `VGTooltip` — tooltip/popover подсказки с Data API, автопозиционированием и закрытием по `Esc`/клику вне элемента.
147
+
148
+ ### VGSelect: автоматический поиск
149
+
150
+ По умолчанию `autosearch: true`: поле поиска автоматически появляется, если в `<select>` больше 7 непустых видимых опций. Число вместо `true` задаёт собственный порог:
151
+
152
+ ```js
153
+ VGSelect.init(select, { autosearch: 10 }); // поиск появится при 11+ опциях
154
+ VGSelect.init(select, { autosearch: 15 }); // поиск появится при 16+ опциях
155
+ VGSelect.init(select, { autosearch: false }); // автоматический поиск отключён
156
+ ```
157
+
158
+ Ручное включение поиска не зависит от количества опций и имеет приоритет над `autosearch`:
159
+
160
+ ```js
161
+ VGSelect.init(select, {
162
+ search: { enabled: true }, // поиск будет виден даже для двух опций
163
+ });
164
+ ```
165
+
166
+ `search.remote: true` также всегда включает поле поиска: remote-режим не зависит от количества локальных опций.
167
+
168
+ Те же настройки через Data API:
169
+
170
+ ```html
171
+ <select class="vg-select" data-autosearch="10">...</select>
172
+ <select class="vg-select" data-search-enabled="true">...</select>
173
+ <select class="vg-select" data-search-remote="true" data-search-route="/api/search">...</select>
174
+ ```
175
+
176
+ ## Authors
149
177
 
150
178
  [VEGAS STUDIO](https://vegas-dev.com) Russia
151
179
 
@@ -1,7 +1,8 @@
1
- import BaseModule from "../../base-module";
2
- import VGModal from "../../vgmodal";
1
+ import BaseModule from "../../base-module";
2
+ import VGModal from "../../vgmodal";
3
+ import VGDropdown from "../../vgdropdown";
3
4
 
4
- import { isElement, isVisible, makeRandomString, mergeDeepObject } from "../../../utils/js/functions";
5
+ import { execute, isElement, isVisible, makeRandomString, mergeDeepObject, reflow } from "../../../utils/js/functions";
5
6
  import { getSVG } from "../../module-fn";
6
7
  import {Classes, Manipulator} from "../../../utils/js/dom/manipulator";
7
8
  import Selectors from "../../../utils/js/dom/selectors";
@@ -13,9 +14,12 @@ import Params from "../../../utils/js/components/params";
13
14
  /**
14
15
  * Константы
15
16
  */
16
- const CLASS_NAME_ALERT = "vg-alert";
17
- const DATA_AGREE = "data-vg-alert-agree";
18
- const DATA_CANCEL = "data-vg-alert-cancel";
17
+ const CLASS_NAME_ALERT = "vg-alert";
18
+ const CLASS_NAME_SHOW = "show";
19
+ const CLASS_NAME_FADE = "fade";
20
+ const CLASS_NAME_CLOSING = "closing";
21
+ const DATA_AGREE = "data-vg-alert-agree";
22
+ const DATA_CANCEL = "data-vg-alert-cancel";
19
23
 
20
24
  const NAME = "alert";
21
25
  const NAME_KEY = "vg.alert";
@@ -27,11 +31,22 @@ class VGAlert {
27
31
  constructor(params = {}, lang = 'ru') {
28
32
  this.lang = lang;
29
33
  this._defaultParams = {
30
- render: {
31
- type: "modal", // modal or overlay,
32
- dismiss: false,
33
- },
34
- ajax: {
34
+ render: {
35
+ type: "modal", // modal, overlay or dropdown
36
+ dismiss: false,
37
+ },
38
+ dropdown: {
39
+ placement: "auto",
40
+ hover: false,
41
+ animation: {
42
+ fade: true,
43
+ enable: false,
44
+ in: "animate__flipInY",
45
+ out: "animate__flipOutY",
46
+ delay: 300,
47
+ },
48
+ },
49
+ ajax: {
35
50
  route: "",
36
51
  target: "",
37
52
  method: "get",
@@ -130,55 +145,83 @@ class VGAlert {
130
145
  render: overlay.render,
131
146
  type: 'overlay'
132
147
  }
133
- } else if (context._params.render.type === "modal") {
134
- const modal = context._buildModal();
135
- return {
136
- element: modal._element,
137
- render: modal,
138
- type: 'modal'
139
- }
140
- }
141
- };
142
-
143
- const {element: container, render, type} = getContainer();
144
-
145
- if (type === 'modal') {
146
- render.show();
147
- }
148
+ } else if (context._params.render.type === "modal") {
149
+ const modal = context._buildModal();
150
+ return {
151
+ element: modal._element,
152
+ render: modal,
153
+ type: 'modal'
154
+ }
155
+ } else if (context._params.render.type === "dropdown") {
156
+ const dropdown = context._buildDropdown();
157
+
158
+ if (!dropdown.element || !dropdown.render) {
159
+ const modal = context._buildModal();
160
+ return {
161
+ element: modal._element,
162
+ render: modal,
163
+ type: 'modal'
164
+ }
165
+ }
166
+
167
+ return {
168
+ element: dropdown.element,
169
+ render: dropdown.render,
170
+ type: 'dropdown'
171
+ }
172
+ }
173
+ };
174
+
175
+ const {element: container, render, type} = getContainer();
176
+
177
+ if (type === 'modal' || type === 'dropdown') {
178
+ render.show();
179
+ } else if (type === 'overlay') {
180
+ context._showOverlay(container);
181
+ }
148
182
 
149
183
  const agreeBtn = Selectors.find(`[${DATA_AGREE}]`, container);
150
184
  const cancelBtn = Selectors.find(`[${DATA_CANCEL}]`, container);
151
185
 
152
- return new Promise((resolve, reject) => {
153
- const closeAlert = (isHide = true) => {
154
- if (type === 'modal') {
155
- render.hide();
156
- return;
157
- }
158
-
159
- if (type === 'overlay') {
160
- container.remove();
161
-
162
- if (context._params.render.dismiss && isHide) {
163
- render.hide();
164
- }
165
- }
166
- };
167
-
168
- const handleAgree = (e) => {
169
- e.preventDefault();
170
- cleanup();
171
- resolve({
186
+ return new Promise((resolve, reject) => {
187
+ let isSettled = false;
188
+
189
+ const closeAlert = (isHide = true) => {
190
+ if (type === 'modal') {
191
+ render.hide();
192
+ return;
193
+ }
194
+
195
+ if (type === 'dropdown') {
196
+ render.hide();
197
+ return;
198
+ }
199
+
200
+ if (type === 'overlay') {
201
+ context._hideOverlay(container, () => {
202
+ if (context._params.render.dismiss && isHide) {
203
+ render.hide();
204
+ }
205
+ });
206
+ }
207
+ };
208
+
209
+ const handleAgree = (e) => {
210
+ e.preventDefault();
211
+ isSettled = true;
212
+ cleanup();
213
+ resolve({
172
214
  accepted: true,
173
215
  timestamp: new Date(),
174
216
  });
175
217
  closeAlert();
176
218
  };
177
219
 
178
- const handleCancel = (e) => {
179
- e.preventDefault();
180
- cleanup();
181
- reject({
220
+ const handleCancel = (e) => {
221
+ e.preventDefault();
222
+ isSettled = true;
223
+ cleanup();
224
+ reject({
182
225
  accepted: false,
183
226
  timestamp: new Date(),
184
227
  });
@@ -196,12 +239,25 @@ class VGAlert {
196
239
  }
197
240
  };
198
241
 
199
- const cleanup = () => {
200
- isAlertOpen = false;
201
- document.removeEventListener("keydown", handleKeydown);
202
- if (agreeBtn) agreeBtn.removeEventListener("click", handleAgree);
203
- if (cancelBtn) cancelBtn.removeEventListener("click", handleCancel);
204
- };
242
+ const cleanup = () => {
243
+ isAlertOpen = false;
244
+ document.removeEventListener("keydown", handleKeydown);
245
+ document.removeEventListener("click", handleDropdownClickOutside);
246
+ if (agreeBtn) agreeBtn.removeEventListener("click", handleAgree);
247
+ if (cancelBtn) cancelBtn.removeEventListener("click", handleCancel);
248
+ };
249
+
250
+ const handleDropdownClickOutside = (e) => {
251
+ if (type !== 'dropdown') return;
252
+
253
+ const toggle = context._params.relatedTarget;
254
+
255
+ if (container.contains(e.target) || (isElement(toggle) && toggle.contains(e.target))) {
256
+ return;
257
+ }
258
+
259
+ handleCancel(e);
260
+ };
205
261
 
206
262
  if (context._params.mode === "confirm") {
207
263
  if (agreeBtn) agreeBtn.addEventListener("click", handleAgree);
@@ -214,19 +270,42 @@ class VGAlert {
214
270
 
215
271
  document.addEventListener("keydown", handleKeydown);
216
272
 
217
- if (type === 'modal') {
218
- container.addEventListener("vg.modal.hide", () => {
219
- cleanup();
220
- reject({
273
+ if (type === 'modal') {
274
+ container.addEventListener("vg.modal.hide", () => {
275
+ if (isSettled) return;
276
+ isSettled = true;
277
+ cleanup();
278
+ reject({
221
279
  accepted: false,
222
280
  timestamp: new Date(),
223
281
  });
224
- }, {once: true});
225
- }
226
-
227
- container.focus();
228
- });
229
- }
282
+ }, {once: true});
283
+ }
284
+
285
+ if (type === 'dropdown') {
286
+ document.addEventListener("click", handleDropdownClickOutside);
287
+
288
+ container.addEventListener("vg.dropdown.hidden", () => {
289
+ if (container._vgAlertDropdownParent && !container._vgAlertDropdownParentWasSet) {
290
+ container._vgAlertDropdownParent.classList.remove('vg-dropdown');
291
+ }
292
+
293
+ container.remove();
294
+
295
+ if (isSettled) return;
296
+
297
+ isSettled = true;
298
+ cleanup();
299
+ reject({
300
+ accepted: false,
301
+ timestamp: new Date(),
302
+ });
303
+ }, {once: true});
304
+ }
305
+
306
+ container.focus();
307
+ });
308
+ }
230
309
 
231
310
  static confirm(elem, options = {}) {
232
311
  let lang = 'ru';
@@ -339,9 +418,9 @@ class VGAlert {
339
418
  );
340
419
  }
341
420
 
342
- _buildOverlay() {
343
- const targetContainers = ['.vg-modal', '.vg-sidebar'];
344
- const relatedTarget = this._params.relatedTarget;
421
+ _buildOverlay() {
422
+ const targetContainers = ['.vg-modal', '.vg-sidebar'];
423
+ const relatedTarget = this._params.relatedTarget;
345
424
  const containerWrap = isElement(relatedTarget)
346
425
  ? relatedTarget.closest(targetContainers.join(', '))
347
426
  : null;
@@ -356,20 +435,129 @@ class VGAlert {
356
435
  const modal = VGModal.getOrCreateInstance(containerWrap);
357
436
  const container = Selectors.find('.vg-modal-content', modal._element) || containerWrap;
358
437
 
359
- const overlay = document.createElement('div');
360
-
361
- overlay.className = `${CLASS_NAME_ALERT}-overlay`;
362
- overlay.append(this._buildContent());
438
+ const overlay = document.createElement('div');
439
+
440
+ overlay.className = `${CLASS_NAME_ALERT}-overlay`;
441
+ overlay.append(this._buildContent());
363
442
 
364
443
  container.append(overlay);
365
444
 
366
445
  return {
367
446
  element: overlay,
368
- render: modal,
369
- };
370
- }
371
-
372
- _createButton(container, key) {
447
+ render: modal,
448
+ };
449
+ }
450
+
451
+ _showOverlay(overlay) {
452
+ if (!overlay) return;
453
+
454
+ reflow(overlay);
455
+ requestAnimationFrame(() => {
456
+ overlay.classList.add(CLASS_NAME_SHOW);
457
+ reflow(overlay);
458
+ window.setTimeout(() => {
459
+ if (overlay.isConnected) {
460
+ overlay.classList.add(CLASS_NAME_FADE);
461
+ }
462
+ }, 15);
463
+ });
464
+ }
465
+
466
+ _hideOverlay(overlay, callback) {
467
+ if (!overlay) {
468
+ execute(callback);
469
+ return;
470
+ }
471
+
472
+ const removeOverlay = () => {
473
+ overlay.classList.remove(CLASS_NAME_SHOW);
474
+ overlay.classList.remove(CLASS_NAME_CLOSING);
475
+ overlay.remove();
476
+ execute(callback);
477
+ };
478
+
479
+ overlay.classList.add(CLASS_NAME_CLOSING);
480
+
481
+ const wrapper = Selectors.find(`.${CLASS_NAME_ALERT}-wrapper`, overlay);
482
+ const wrapperDuration = this._getElementTransitionDuration(wrapper);
483
+
484
+ window.setTimeout(() => {
485
+ overlay.classList.remove(CLASS_NAME_FADE);
486
+
487
+ const backdropDuration = this._getElementTransitionDuration(overlay, '::before');
488
+ if (!backdropDuration) {
489
+ removeOverlay();
490
+ return;
491
+ }
492
+
493
+ window.setTimeout(removeOverlay, backdropDuration);
494
+ }, wrapperDuration);
495
+ }
496
+
497
+ _getElementTransitionDuration(element, pseudoElement = null) {
498
+ if (!element || typeof window === 'undefined') return 0;
499
+
500
+ const styles = window.getComputedStyle(element, pseudoElement);
501
+ const durations = styles.transitionDuration.split(',');
502
+ const delays = styles.transitionDelay.split(',');
503
+
504
+ return Math.max(...durations.map((duration, index) => {
505
+ return this._parseCssTime(duration) + this._parseCssTime(delays[index] || delays[0] || '0s');
506
+ }));
507
+ }
508
+
509
+ _parseCssTime(value) {
510
+ const time = Number.parseFloat(value);
511
+ if (!Number.isFinite(time)) return 0;
512
+
513
+ return value.trim().endsWith('ms') ? time : time * 1000;
514
+ }
515
+
516
+ _buildDropdown() {
517
+ const relatedTarget = this._params.relatedTarget;
518
+
519
+ if (!isElement(relatedTarget) || !relatedTarget.parentNode) {
520
+ return {
521
+ element: null,
522
+ render: null,
523
+ };
524
+ }
525
+
526
+ const id = `${CLASS_NAME_ALERT}-dropdown-${makeRandomString()}`;
527
+ const parent = relatedTarget.parentNode;
528
+ const container = document.createElement('div');
529
+ const dropdownContainer = document.createElement('div');
530
+ const isDropdownParent = parent.classList.contains('vg-dropdown');
531
+
532
+ parent.classList.add('vg-dropdown');
533
+
534
+ container.id = id;
535
+ container.className = `vg-dropdown-content ${CLASS_NAME_ALERT}-dropdown`;
536
+ container.setAttribute('tabindex', '-1');
537
+ container.setAttribute('role', 'dialog');
538
+ container._vgAlertDropdownParent = parent;
539
+ container._vgAlertDropdownParentWasSet = isDropdownParent;
540
+
541
+ dropdownContainer.className = 'vg-dropdown-container';
542
+ dropdownContainer.append(this._buildContent());
543
+ container.append(dropdownContainer);
544
+
545
+ parent.append(container);
546
+
547
+ const dropdown = VGDropdown.getOrCreateInstance(relatedTarget, mergeDeepObject({
548
+ placement: 'bottom-start',
549
+ }, this._params.dropdown));
550
+
551
+ dropdown._drop = container;
552
+ dropdown._parent = parent;
553
+
554
+ return {
555
+ element: container,
556
+ render: dropdown,
557
+ };
558
+ }
559
+
560
+ _createButton(container, key) {
373
561
  const button = this._params.buttons[key];
374
562
  if (!button || button.element) {
375
563
  container.insertAdjacentHTML("beforeend", button?.element || "");
@@ -1 +1,14 @@
1
- $alert-map: ();
1
+ $alert-map: (
2
+ overlay-z-index: 1,
3
+ overlay-backdrop-bg: rgba(0, 0, 0, 0),
4
+ overlay-backdrop-show-bg: rgba(0, 0, 0, 0.25),
5
+ overlay-backdrop-transition: background-color .05s linear,
6
+ overlay-wrapper-bg: #fff,
7
+ overlay-wrapper-width: 90%,
8
+ overlay-wrapper-top: 1rem,
9
+ overlay-wrapper-start: 50%,
10
+ overlay-wrapper-transform: translate(-50%, -1rem),
11
+ overlay-wrapper-show-transform: translate(-50%, 0),
12
+ overlay-wrapper-transition: transform .12s ease-out,
13
+ buttons-bg: #F5F5F5,
14
+ );
@@ -16,29 +16,103 @@
16
16
  --vg-modal-padding: 0 !important;
17
17
  --vg-modal-width: 520px !important;
18
18
  --vg-modal-margin: 65px !important;
19
- }
20
-
21
- &-overlay {
22
- width: 100%;
23
- height: 100%;
24
- position: absolute;
25
- top: 0;
26
- left: 0;
27
- background: rgba(0, 0, 0, 0.75);
28
-
29
- .vg-alert-wrapper {
30
- background-color: white;
31
- width: 90%;
32
- position: absolute;
33
- left: 50%;
34
- transform: translateX(-50%);
35
- top: 1rem;
36
- }
37
- }
38
-
39
- &-wrapper {
40
- @include mix-vars('alert', $alert-map);
41
- @include mix-alert-color-mode($class: vg-alert);
19
+ }
20
+
21
+ &-overlay {
22
+ @include mix-vars('alert', $alert-map);
23
+ width: 100%;
24
+ height: 100%;
25
+ position: absolute;
26
+ top: 0;
27
+ left: 0;
28
+ z-index: var(--vg-alert-overlay-z-index);
29
+ pointer-events: none;
30
+
31
+ &::before {
32
+ content: '';
33
+ position: absolute;
34
+ inset: 0;
35
+ background-color: var(--vg-alert-overlay-backdrop-bg);
36
+ transition: var(--vg-alert-overlay-backdrop-transition);
37
+ }
38
+
39
+ &.fade.show {
40
+ pointer-events: auto;
41
+
42
+ &::before {
43
+ background-color: var(--vg-alert-overlay-backdrop-show-bg);
44
+ }
45
+
46
+ .vg-alert-wrapper {
47
+ transform: var(--vg-alert-overlay-wrapper-show-transform);
48
+ }
49
+
50
+ &.closing {
51
+ pointer-events: none;
52
+
53
+ .vg-alert-wrapper {
54
+ transform: var(--vg-alert-overlay-wrapper-transform);
55
+ }
56
+ }
57
+ }
58
+
59
+ .vg-alert-wrapper {
60
+ background-color: var(--vg-alert-overlay-wrapper-bg);
61
+ width: var(--vg-alert-overlay-wrapper-width);
62
+ position: absolute;
63
+ left: var(--vg-alert-overlay-wrapper-start);
64
+ z-index: 1;
65
+ transform: var(--vg-alert-overlay-wrapper-transform);
66
+ top: var(--vg-alert-overlay-wrapper-top);
67
+ transition: var(--vg-alert-overlay-wrapper-transition);
68
+ }
69
+ }
70
+
71
+ &-dropdown {
72
+ width: 360px;
73
+ max-width: calc(100vw - 2rem);
74
+
75
+ .vg-dropdown-container {
76
+ overflow: hidden;
77
+ }
78
+
79
+ .vg-alert-content {
80
+ padding: 1rem;
81
+ gap: .75rem;
82
+ align-items: flex-start;
83
+
84
+ .vg-alert-content--title {
85
+ font-size: 1rem;
86
+ line-height: 1.25;
87
+ }
88
+
89
+ .vg-alert-content--description {
90
+ font-size: .875rem;
91
+ line-height: 1.4;
92
+ }
93
+
94
+ .vg-alert-content--icon {
95
+ svg {
96
+ width: 32px;
97
+ height: 32px;
98
+ }
99
+ }
100
+ }
101
+
102
+ .vg-alert-buttons {
103
+ padding: .75rem 1rem;
104
+
105
+ .btn {
106
+ padding: .5rem .875rem;
107
+ font-size: .875rem;
108
+ line-height: 1.25;
109
+ }
110
+ }
111
+ }
112
+
113
+ &-wrapper {
114
+ @include mix-vars('alert', $alert-map);
115
+ @include mix-alert-color-mode($class: vg-alert);
42
116
  border-radius: var(--vg-border-radius);
43
117
  overflow: hidden;
44
118
 
@@ -68,12 +142,12 @@
68
142
  }
69
143
  }
70
144
  }
71
- }
72
-
73
- .vg-alert-buttons {
74
- background-color: #F5F5F5;
75
- padding: 1rem 1.5rem;
76
- display: flex;
145
+ }
146
+
147
+ .vg-alert-buttons {
148
+ background-color: var(--vg-alert-buttons-bg);
149
+ padding: 1rem 1.5rem;
150
+ display: flex;
77
151
  align-items: center;
78
152
  justify-content: flex-end;
79
153
  gap: .5rem;