vgapp 1.5.4 → 1.5.6

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.
@@ -1,4 +1,8 @@
1
- import BaseModule from "../../base-module";
1
+ /**
2
+ * Описание: адаптивная навигация VGNav с выпадающими пунктами и гамбургером.
3
+ * Возможности: click/hover, вложенные меню, позиционирование, события и интеграция с VGSidebar.
4
+ */
5
+ import BaseModule from "../../base-module";
2
6
  import Selectors from "../../../utils/js/dom/selectors";
3
7
  import {
4
8
  execute,
@@ -29,7 +33,7 @@ const CLASS_NAME_ACTIVE = 'active';
29
33
  /**
30
34
  * Constants toggle
31
35
  */
32
- const SELECTOR_DATA_TOGGLE = '.' + CLASS_NAME + ' a';
36
+ const SELECTOR_DATA_TOGGLE = '.' + CLASS_NAME + ' .dropdown > a';
33
37
 
34
38
  /**
35
39
  * Constants Events
@@ -215,34 +219,43 @@ class VGNav extends BaseModule {
215
219
  }
216
220
  }
217
221
 
218
- show(relatedTarget) {
219
- let target = relatedTarget.relatedTarget;
220
-
221
- if (!target || isDisabled(target)) return;
222
-
223
- if (!target.closest('.dropdown-content')) {
224
- target.classList.add('first');
225
- }
226
-
227
- const showEvent = EventHandler.trigger(target, EVENT_KEY_SHOW, { relatedTarget });
228
- if (showEvent.defaultPrevented) return;
229
-
230
- let drop = Selectors.find('.dropdown-content', target),
231
- link = target.firstElementChild;
222
+ show(relatedTarget) {
223
+ let target = relatedTarget.relatedTarget;
224
+
225
+ if (!target || isDisabled(target)) return;
226
+ const drop = Selectors.find(':scope > .dropdown-content', target);
227
+ if (!drop) return;
228
+
229
+ const showEvent = EventHandler.trigger(target, EVENT_KEY_SHOW, { relatedTarget });
230
+ if (showEvent.defaultPrevented) return;
231
+
232
+ if (!target.closest('.dropdown-content')) {
233
+ target.classList.add('first');
234
+ }
235
+
236
+ const link = target.firstElementChild;
232
237
 
233
238
  if (link) link.setAttribute('aria-expanded', 'true');
234
- drop.classList.add(CLASS_NAME_SHOW);
235
- target.classList.add(CLASS_NAME_ACTIVE);
236
-
237
- const $placement = new Placement({
238
- reference: target,
239
- drop: drop,
240
- placement: 'bottom-start',
241
- fallbackPlacements: ['top-start', 'bottom-end', 'top-end'],
242
- offset: [0, 6],
243
- boundary: 'clippingParents',
244
- autoFlip: true,
245
- overflowProtection: true
239
+ drop.classList.add(CLASS_NAME_SHOW);
240
+ target.classList.add(CLASS_NAME_ACTIVE);
241
+ // Координаты Placement имеют приоритет над старой CSS-схемой top/bottom/right.
242
+ drop.style.bottom = 'auto';
243
+ drop.style.right = 'auto';
244
+ const openToSide = !!target.closest('.dropdown-content') || this._params.placement === 'vertical';
245
+
246
+ const $placement = new Placement({
247
+ reference: target,
248
+ drop: drop,
249
+ placement: openToSide ? 'right-start' : 'bottom-start',
250
+ fallbackPlacements: openToSide
251
+ ? ['left-start', 'right-end', 'left-end', 'bottom-start', 'top-start']
252
+ : ['top-start', 'bottom-end', 'top-end'],
253
+ offset: openToSide ? [6, 0] : [0, 6],
254
+ boundary: 'clippingParents',
255
+ autoFlip: true,
256
+ overflowProtection: true,
257
+ clamp: true,
258
+ isMerge: false
246
259
  });
247
260
 
248
261
  $placement._setPlacement();
@@ -264,13 +277,8 @@ class VGNav extends BaseModule {
264
277
  this._queueCallback(completeCallBack, drop, true, 10);
265
278
  }
266
279
 
267
- hide(relatedTarget) {
268
- const _this = this;
269
- if ('ontouchstart' in document.documentElement) {
270
- for (const element of [].concat(...document.body.children)) {
271
- EventHandler.off(element, 'mouseover', noop);
272
- }
273
- }
280
+ hide(relatedTarget) {
281
+ const _this = this;
274
282
 
275
283
  let element = relatedTarget.relatedTarget;
276
284
 
@@ -288,7 +296,7 @@ class VGNav extends BaseModule {
288
296
  element.classList.remove('first');
289
297
  }
290
298
 
291
- [...Selectors.findAll('.' + CLASS_NAME_SHOW, element)].forEach(function (el, index) {
299
+ [...Selectors.findAll('.dropdown-content.' + CLASS_NAME_SHOW, element)].forEach(function (el) {
292
300
  el.classList.remove(CLASS_NAME_FADE);
293
301
 
294
302
  let parent = el.closest('.dropdown');
@@ -299,21 +307,14 @@ class VGNav extends BaseModule {
299
307
  let link = el.previousElementSibling;
300
308
  if (link) link.setAttribute('aria-expanded', 'false');
301
309
 
302
- if (index === 0) {
303
- const completeCallback = () => {
304
- el.classList.remove(CLASS_NAME_SHOW);
305
- EventHandler.trigger(el, EVENT_KEY_HIDDEN, relatedTarget);
306
- };
307
-
308
- _this._queueCallback(completeCallback, el, true, 500);
309
- }
310
-
311
- const dropData = _this._openDrops.get(el);
312
- if (dropData) {
313
- window.removeEventListener('scroll', dropData.scrollHandler, { capture: true });
314
- window.removeEventListener('resize', dropData.resizeHandler);
315
- _this._openDrops.delete(el);
316
- }
310
+ const completeCallback = () => {
311
+ if (parent.classList.contains(CLASS_NAME_ACTIVE)) return;
312
+ el.classList.remove(CLASS_NAME_SHOW);
313
+ EventHandler.trigger(el, EVENT_KEY_HIDDEN, relatedTarget);
314
+ };
315
+
316
+ _this._queueCallback(completeCallback, el, true, 500);
317
+ _this._cleanupDrop(el);
317
318
  });
318
319
  }
319
320
  }
@@ -341,13 +342,16 @@ class VGNav extends BaseModule {
341
342
  }
342
343
  }
343
344
 
344
- _cleanupDrop(drop) {
345
- const dropData = this._openDrops.get(drop);
346
- if (dropData) {
347
- window.removeEventListener('scroll', dropData.scrollHandler, { capture: true });
348
- window.removeEventListener('resize', dropData.resizeHandler);
349
- this._openDrops.delete(drop);
350
- }
345
+ _cleanupDrop(drop) {
346
+ const dropData = this._openDrops.get(drop);
347
+ if (dropData) {
348
+ this._openDrops.delete(drop);
349
+ // Обработчики общие для экземпляра: другие открытые уровни ещё нуждаются в них.
350
+ if (!this._openDrops.size) {
351
+ window.removeEventListener('scroll', dropData.scrollHandler, { capture: true });
352
+ window.removeEventListener('resize', dropData.resizeHandler);
353
+ }
354
+ }
351
355
  }
352
356
 
353
357
  _isElementInViewport(el) {
@@ -1,4 +1,8 @@
1
- const SKIN_CLASS_DEFAULT = 'is-skin-default';
1
+ /**
2
+ * Описание: визуальные режимы VGRangeSlider — default, ruler и status.
3
+ * Возможности: шкала с адаптивными координатами, активные отметки и пороговые цвета с текстовыми статусами.
4
+ */
5
+ const SKIN_CLASS_DEFAULT = 'is-skin-default';
2
6
  const SKIN_CLASS_RULER = 'is-skin-ruler';
3
7
  const SKIN_CLASS_STATUS = 'is-skin-status';
4
8
  const SKIN_CLASS_RULER_DIM = 'is-ruler-dim-inactive';
@@ -206,8 +210,9 @@ const syncRangeSliderSkin = (skin, state, helpers) => {
206
210
 
207
211
  const isRange = state.from !== state.to || helpers.isRange;
208
212
 
209
- skin.ticks.forEach((tick, index) => {
210
- const value = skin.values[index];
213
+ skin.ticks.forEach((tick, index) => {
214
+ const value = skin.values[index];
215
+ tick.style.left = `${helpers.toPositionPx(value)}px`;
211
216
  const isActive = isRange
212
217
  ? value >= state.from && value <= state.to
213
218
  : value <= state.from;
@@ -1,4 +1,8 @@
1
- import BaseModule from "../../base-module";
1
+ /**
2
+ * Описание: одиночные и диапазонные слайдеры VGRangeSlider на нативных range-input.
3
+ * Возможности: Data API, шкала и статусы, форматирование, синхронизация формы, события и управление значениями.
4
+ */
5
+ import BaseModule from "../../base-module";
2
6
  import EventHandler from "../../../utils/js/dom/event";
3
7
  import Selectors from "../../../utils/js/dom/selectors";
4
8
  import { mergeDeepObject, normalizeData } from "../../../utils/js/functions";
@@ -259,9 +263,10 @@ class VGRangeSlider extends BaseModule {
259
263
  this._dom.labelFrom = Selectors.find('.vg-range-slider__label--from', root);
260
264
  this._dom.labelTo = Selectors.find('.vg-range-slider__label--to', root);
261
265
  this._dom.labelSeparator = Selectors.find('.vg-range-slider__separator', root);
262
- this._dom.hiddenMin = root.querySelector('input[data-vgrangeslider-hidden="min"]');
263
- this._dom.hiddenMax = root.querySelector('input[data-vgrangeslider-hidden="max"]');
264
- this._dom.skin = applyRangeSliderSkin(root, this._params, this._state, {
266
+ this._dom.hiddenMin ||= root.querySelector('input[data-vgrangeslider-hidden="min"]');
267
+ this._dom.hiddenMax ||= root.querySelector('input[data-vgrangeslider-hidden="max"]');
268
+ this._dom.skin = applyRangeSliderSkin(root, this._params, this._state, {
269
+ isRange: this._isRange,
265
270
  formatValue: (value) => this._formatValue(value),
266
271
  toPositionPx: (value) => this._toPositionPx(value),
267
272
  });
@@ -338,11 +343,11 @@ class VGRangeSlider extends BaseModule {
338
343
  root.appendChild(this._createLabels(this._isRange));
339
344
  }
340
345
 
341
- if (this._isRange) {
342
- this._ensureHiddenInput(root, 'min', this._params.input?.min, this._params.name?.min);
343
- this._ensureHiddenInput(root, 'max', this._params.input?.max, this._params.name?.max);
344
- } else if (this._params.name?.min) {
345
- this._ensureHiddenInput(root, 'min', this._params.input?.min, this._params.name.min);
346
+ if (this._isRange) {
347
+ this._dom.hiddenMin = this._ensureHiddenInput(root, 'min', this._params.input?.min, this._params.name?.min);
348
+ this._dom.hiddenMax = this._ensureHiddenInput(root, 'max', this._params.input?.max, this._params.name?.max);
349
+ } else if (this._params.name?.min) {
350
+ this._dom.hiddenMin = this._ensureHiddenInput(root, 'min', this._params.input?.min, this._params.name.min);
346
351
  }
347
352
 
348
353
  return root;
@@ -399,7 +404,8 @@ class VGRangeSlider extends BaseModule {
399
404
  if (name) input.name = name;
400
405
  root.appendChild(input);
401
406
  }
402
- input.setAttribute('data-vgrangeslider-hidden', role);
407
+ input.setAttribute('data-vgrangeslider-hidden', role);
408
+ return input;
403
409
  }
404
410
 
405
411
  _bindEvents() {
@@ -442,7 +448,8 @@ class VGRangeSlider extends BaseModule {
442
448
  window.addEventListener('resize', this._handleResize);
443
449
  }
444
450
 
445
- _syncUI() {
451
+ _syncUI() {
452
+ if (!this._isRange) this._state.to = this._state.from;
446
453
  const fromPercent = this._toPercent(this._state.from);
447
454
  const toPercent = this._toPercent(this._isRange ? this._state.to : this._state.from);
448
455
  const fromPosition = this._toPositionPx(this._state.from);
@@ -467,10 +474,11 @@ class VGRangeSlider extends BaseModule {
467
474
  if (this._dom.labelSeparator) this._dom.labelSeparator.hidden = !this._isRange;
468
475
 
469
476
  this._syncTargets();
470
- syncRangeSliderSkin(this._dom.skin, this._state, {
471
- isRange: this._isRange,
472
- params: this._params,
473
- dom: this._dom,
477
+ syncRangeSliderSkin(this._dom.skin, this._state, {
478
+ isRange: this._isRange,
479
+ params: this._params,
480
+ dom: this._dom,
481
+ toPositionPx: (value) => this._toPositionPx(value),
474
482
  });
475
483
  }
476
484
 
@@ -565,8 +573,12 @@ class VGRangeSlider extends BaseModule {
565
573
  const numeric = Number(normalizeData(value));
566
574
  const safeValue = Number.isFinite(numeric) ? numeric : fallback;
567
575
  const clamped = Math.min(Math.max(safeValue, min), max);
568
- const step = this._state?.step || this._params.step || 1;
569
- const stepped = Math.round((clamped - min) / step) * step + min;
576
+ const configuredStep = Number(this._state?.step ?? this._params.step);
577
+ const step = Number.isFinite(configuredStep) && configuredStep > 0 ? configuredStep : 1;
578
+ // Округление не должно выводить значение за max или с нативной сетки шага.
579
+ const maxSteps = Math.floor((max - min) / step + 1e-9);
580
+ const steps = Math.min(Math.round((clamped - min) / step), maxSteps);
581
+ const stepped = steps * step + min;
570
582
  return Number(stepped.toFixed(5));
571
583
  }
572
584
 
@@ -1,4 +1,8 @@
1
- import BaseModule from "../../base-module";
1
+ /**
2
+ * Описание: сворачивание текста и списков VGRollup.
3
+ * Возможности: ограничение высоты, строк и количества элементов, локализация кнопок, Data API, callbacks и события.
4
+ */
5
+ import BaseModule from "../../base-module";
2
6
  import { execute, isDisabled, mergeDeepObject } from "../../../utils/js/functions";
3
7
  import EventHandler from "../../../utils/js/dom/event";
4
8
  import Selectors from "../../../utils/js/dom/selectors";
@@ -98,7 +102,7 @@ class VGRollup extends BaseModule {
98
102
  }
99
103
  };
100
104
 
101
- let lang = Manipulator.get(element, 'data-lang') || ('lang' in params) ? params.lang : defaultParams.lang;
105
+ const lang = Manipulator.get(element, 'data-lang') || params.lang || defaultParams.lang;
102
106
 
103
107
  // Локализация текстов кнопок
104
108
  defaultParams.button.more = lang_buttons(lang, NAME)['show'];
@@ -288,7 +292,8 @@ class VGRollup extends BaseModule {
288
292
 
289
293
  if (isEllipsis && line) {
290
294
  Classes.add(element, this.classes.ellipsis);
291
- element.style.lineClamp = Number(line);
295
+ element.style.lineClamp = Number(line);
296
+ element.style.setProperty('-webkit-line-clamp', String(Number(line)));
292
297
  } else if (isEllipsis) {
293
298
  console.error("Переменная [data-line] или параметр[line] не должны быть пустыми");
294
299
  }
@@ -377,13 +382,14 @@ class VGRollup extends BaseModule {
377
382
 
378
383
  if (this._params.ellipsis.line) {
379
384
  Classes.add(el, this.classes.ellipsis);
380
- el.style.lineClamp = this._params.ellipsis.line;
385
+ el.style.lineClamp = this._params.ellipsis.line;
386
+ el.style.setProperty('-webkit-line-clamp', String(this._params.ellipsis.line));
381
387
  }
382
388
 
383
389
  if (this._params.fade) Classes.add(el, this.classes.fade);
384
390
  if (this._params.transition) Classes.add(el, this.classes.transition);
385
391
 
386
- execute(this._params.callbacks.expand, [el, this])
392
+ execute(this._params.callbacks.collapse, [el, this])
387
393
  } else if (content === 'elements') {
388
394
  const items = Selectors.findAll('.' + this._params.elements, el);
389
395
  items.forEach((item, index) => {
@@ -399,7 +405,9 @@ class VGRollup extends BaseModule {
399
405
  } else {
400
406
  const { hidden, ellipsis, fade } = this.classes;
401
407
  Classes.remove(el, [hidden, ellipsis, fade]);
402
- Manipulator.remove(el, 'style');
408
+ el.style.removeProperty('height');
409
+ el.style.removeProperty('line-clamp');
410
+ el.style.removeProperty('-webkit-line-clamp');
403
411
 
404
412
  if (this._params.content === 'elements') {
405
413
  const items = Selectors.findAll('.' + this._params.elements, el);
@@ -455,4 +463,4 @@ EventHandler.on(document, EVENT_KEY_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, functi
455
463
  VGRollup.toggle(target, this);
456
464
  });
457
465
 
458
- export default VGRollup;
466
+ export default VGRollup;
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Описание: константы и параметры по умолчанию базовой таблицы VGTable.
3
- * Возможности: единая настройка Data API, i18n, wrapper, remote-запросов, фильтров, sticky-заголовка, колонок, строк, сортировки, выбора и пагинации.
4
- */
3
+ * Возможности: единая настройка Data API, i18n, wrapper, remote, фильтров, sticky, колонок, строк, выбора и адаптивной пагинации.
4
+ */
5
5
 
6
6
  /**
7
7
  * Служебные константы модуля.
8
8
  */
9
9
  // Имя компонента в публичном API. Варианты: фиксированное значение 'table'.
10
- const NAME = 'table';
10
+ const NAME = 'table';
11
11
  // Ключ экземпляра в Data API. Варианты: фиксированное значение 'vg.table'.
12
12
  const NAME_KEY = `vg.${NAME}`;
13
13
  // Селектор автоматической инициализации. Варианты: элементы с атрибутом data-vg-table.
@@ -21,7 +21,16 @@ const TABLE_CONTAINER_SELECTOR = '.vg-table-container';
21
21
  // Маркер container, созданного компонентом. Варианты: пустой data-атрибут.
22
22
  const GENERATED_TABLE_CONTAINER_ATTRIBUTE = 'data-vg-table-generated-container';
23
23
 
24
- const DEFAULT_OPTIONS = {
24
+ const DEFAULT_OPTIONS = {
25
+ /** Профили представления: базовые параметры + все достигнутые границы xs–xxl. */
26
+ responsive: {
27
+ // Включается явно; Data API: data-responsive-enabled.
28
+ enabled: false,
29
+ // Локальные переопределения границ общего Responsive; пустой объект сохраняет глобальные значения.
30
+ breakpoints: {},
31
+ // Профили принимают pagination: maxButtons, align, position, size.enabled/label, quick.enabled.
32
+ xs: {}, sm: {}, md: {}, lg: {}, xl: {}, xxl: {},
33
+ },
25
34
  // Активная локаль встроенного интерфейса. Data API: data-locale. Варианты: 'ru' | 'en' | региональный BCP 47 код.
26
35
  locale: 'ru',
27
36
  // Пользовательские словари, объединяемые со встроенными ru/en. Data API отсутствует; варианты: объект {locale: {group: {key: value}}}.
@@ -359,7 +368,9 @@ const DEFAULT_OPTIONS = {
359
368
  // Задаёт число страниц, после которого разрешены многоточие и quick='auto'. Варианты: целое число >= 1.
360
369
  threshold: 5,
361
370
  // Задаёт максимальное число соседних страниц между первой и последней. Варианты: целое число >= 1.
362
- visible: 5,
371
+ visible: 5,
372
+ // Лимит номеров и многоточий без prev/next; null сохраняет visible/threshold. Data API: data-pagination-max-buttons.
373
+ maxButtons: null,
363
374
 
364
375
  /** Поле и VGDropdown для выбора количества строк. */
365
376
  size: {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Описание: локальная и remote-пагинация строк базовой таблицы VGTable.
3
- * Возможности: выбор страницы и размера, серверная meta, многоточия, быстрый переход, сохранение, Fixed Header container и публичные события.
3
+ * Возможности: выбор страницы и размера, серверная meta, лимит кнопок, responsive-представление без запросов, многоточия, быстрый переход и сохранение.
4
4
  */
5
5
  import EventHandler from "../../../utils/js/dom/event";
6
6
  import VGDropdown from "../../vgdropdown";
@@ -16,7 +16,10 @@ class _pagination {
16
16
  this._options = options;
17
17
  this._host = table.closest('.vg-table-wrapper') || table.parentElement;
18
18
  this._containers = [];
19
- this._dropdowns = [];
19
+ this._dropdowns = [];
20
+ this._externalVisibility = new Map();
21
+ this._manageExternalVisibility = options.responsive === true;
22
+ this._updatingPresentation = false;
20
23
  this._page = this._positiveInt(options.page, 1);
21
24
  this._perPage = this._clampPerPage(options.per);
22
25
  this._remote = options.remote === true;
@@ -29,20 +32,24 @@ class _pagination {
29
32
  this._boundFocusOut = this._handleFocusOut.bind(this);
30
33
  }
31
34
 
32
- init() {
33
- this._restoreState();
34
- this._containers = this._resolveContainers();
35
- this._containers.forEach((container) => {
35
+ init() {
36
+ this._restoreState();
37
+ this._containers = this._resolveContainers();
38
+ this._bindContainers();
39
+ this.refresh();
40
+ }
41
+
42
+ _bindContainers() {
43
+ this._containers.forEach((container) => {
36
44
  container.addEventListener('click', this._boundClick);
37
45
  container.addEventListener('change', this._boundChange);
38
46
  container.addEventListener('keydown', this._boundKeydown);
39
47
  container.addEventListener('focusin', this._boundFocusIn);
40
48
  container.addEventListener('focusout', this._boundFocusOut);
41
49
  });
42
- this.refresh();
43
- }
44
-
45
- dispose() {
50
+ }
51
+
52
+ _releaseContainers() {
46
53
  this._disposeDropdowns();
47
54
  this._containers.forEach((container) => {
48
55
  container.removeEventListener('click', this._boundClick);
@@ -50,15 +57,69 @@ class _pagination {
50
57
  container.removeEventListener('keydown', this._boundKeydown);
51
58
  container.removeEventListener('focusin', this._boundFocusIn);
52
59
  container.removeEventListener('focusout', this._boundFocusOut);
53
- if (container.hasAttribute(GENERATED_ATTRIBUTE)) container.remove();
54
- });
60
+ if (container.hasAttribute(GENERATED_ATTRIBUTE)) container.remove();
61
+ });
62
+ this._containers = [];
63
+ }
64
+
65
+ dispose() {
66
+ this._releaseContainers();
67
+ this._externalVisibility.forEach((hidden, container) => { container.hidden = hidden; });
68
+ this._externalVisibility.clear();
55
69
  this._rows().forEach((row) => {
56
70
  row.hidden = row.getAttribute('data-vg-table-expand-hidden') === 'true'
57
71
  || row.hasAttribute('data-vg-table-filter-hidden');
58
72
  row.removeAttribute('data-vg-table-page-row');
59
73
  });
60
- this._containers = [];
61
- }
74
+ this._containers = [];
75
+ }
76
+
77
+ /** Обновляет только панели: не трогает строки, page/per, storage, scroll или onChange. */
78
+ updatePresentation(options) {
79
+ const focus = this._captureFocus();
80
+ const position = this._position();
81
+ this._updatingPresentation = true;
82
+ try {
83
+ this._options = {...this._options, ...options};
84
+ if (position !== this._position()) {
85
+ this._manageExternalVisibility = true;
86
+ this._releaseContainers();
87
+ this._containers = this._resolveContainers();
88
+ this._bindContainers();
89
+ }
90
+ this._renderControls();
91
+ } finally {
92
+ this._updatingPresentation = false;
93
+ }
94
+ this._restoreFocus(focus);
95
+ }
96
+
97
+ _captureFocus() {
98
+ const active = this._table.ownerDocument.activeElement;
99
+ const container = this._containers.find((item) => item.contains(active));
100
+ if (!container) return null;
101
+ const attribute = ['data-pagination-page', 'data-pagination-per-page', 'data-pagination-quick-input', 'data-pagination-quick-button']
102
+ .find((name) => active.hasAttribute(name));
103
+ return {
104
+ position: this._positionFor(container), attribute, value: attribute ? active.getAttribute(attribute) : null,
105
+ inputValue: active.tagName === 'INPUT' ? active.value : null,
106
+ start: active.selectionStart, end: active.selectionEnd,
107
+ };
108
+ }
109
+
110
+ _restoreFocus(focus) {
111
+ if (!focus) return;
112
+ const container = this._containers.find((item) => this._positionFor(item) === focus.position) || this._containers[0];
113
+ if (!container) return;
114
+ const matching = focus.attribute ? Array.from(container.querySelectorAll(`[${focus.attribute}]`))
115
+ .find((item) => !item.disabled && item.getAttribute(focus.attribute) === focus.value) : null;
116
+ const target = matching || container.querySelector('[aria-current="page"]');
117
+ target?.focus({preventScroll: true});
118
+ if (matching && focus.inputValue !== null) {
119
+ matching.value = focus.inputValue;
120
+ if (focus.start !== null && focus.start !== undefined) matching.setSelectionRange(focus.start, focus.end);
121
+ }
122
+ }
62
123
 
63
124
  setPage(page, emit = false, source = 'api') {
64
125
  const nextPage = this._clampPage(page);
@@ -131,7 +192,11 @@ class _pagination {
131
192
  });
132
193
  }
133
194
 
134
- const markup = this._buildMarkup();
195
+ this._renderControls();
196
+ }
197
+
198
+ _renderControls() {
199
+ const markup = this._buildMarkup();
135
200
  this._disposeDropdowns();
136
201
  this._containers.forEach((container) => {
137
202
  container.className = `vg-table-pagination vg-table-pagination--${this._positionFor(container)}`;
@@ -221,8 +286,10 @@ class _pagination {
221
286
  return `<svg class="vg-table-page__ellipsis-chevron vg-table-page__ellipsis-chevron--${direction}" viewBox="0 0 20 20" focusable="false" aria-hidden="true">${paths}</svg>`;
222
287
  }
223
288
 
224
- _buildPages() {
225
- const total = this._totalPages();
289
+ _buildPages() {
290
+ const total = this._totalPages();
291
+ const maxButtons = this._options.maxButtons;
292
+ if (Number.isInteger(maxButtons) && maxButtons >= 3) return this._buildLimitedPages(total, maxButtons);
226
293
  const visible = Math.max(1, this._positiveInt(this._options.visible, 5));
227
294
  const threshold = Math.max(1, this._positiveInt(this._options.threshold, 5));
228
295
  if (this._options.ellipsis === false || total <= threshold || total <= visible + 2) {
@@ -237,8 +304,30 @@ class _pagination {
237
304
  for (let page = start; page <= end; page += 1) pages.push(page);
238
305
  if (end < total - 1) pages.push({direction: 'next'});
239
306
  pages.push(total);
240
- return pages;
241
- }
307
+ return pages;
308
+ }
309
+
310
+ _buildLimitedPages(total, maximum) {
311
+ if (total <= maximum) return this._range(1, total);
312
+ if (maximum < 5 || this._options.ellipsis === false) {
313
+ const start = Math.max(1, Math.min(this._page - Math.floor(maximum / 2), total - maximum + 1));
314
+ return this._range(start, start + maximum - 1);
315
+ }
316
+ // Резервируем края и подбираем наибольшее окно, учитывая оба многоточия в лимите.
317
+ for (let count = maximum - 2; count >= 1; count -= 1) {
318
+ const start = Math.max(2, Math.min(this._page - Math.floor(count / 2), total - count));
319
+ const end = start + count - 1;
320
+ const pages = [1];
321
+ if (start === 3) pages.push(2);
322
+ else if (start > 3) pages.push({direction: 'prev'});
323
+ pages.push(...this._range(start, end));
324
+ if (end === total - 2) pages.push(total - 1);
325
+ else if (end < total - 2) pages.push({direction: 'next'});
326
+ pages.push(total);
327
+ if (pages.length <= maximum) return pages;
328
+ }
329
+ return [this._page];
330
+ }
242
331
 
243
332
  _handleClick(event) {
244
333
  const sizeOption = event.target.closest('[data-pagination-per-page-option]');
@@ -297,7 +386,8 @@ class _pagination {
297
386
  if (input) input.value = String(this._perPage);
298
387
  }
299
388
 
300
- _handleFocusOut(event) {
389
+ _handleFocusOut(event) {
390
+ if (this._updatingPresentation) return;
301
391
  const input = event.target.closest('[data-pagination-per-page]');
302
392
  if (!input) return;
303
393
  const related = event.relatedTarget instanceof Element ? event.relatedTarget : null;
@@ -323,10 +413,14 @@ class _pagination {
323
413
  _resolveContainers() {
324
414
  if (!this._host) return [];
325
415
  const position = this._position();
326
- const existing = Array.from(this._host.querySelectorAll('[data-vg-table-pagination]'));
327
- if (existing.length) {
328
- if (position === 'both') return existing;
329
- return [existing.find((item) => item.getAttribute('data-position') === position) || existing[0]];
416
+ const existing = Array.from(this._host.querySelectorAll('[data-vg-table-pagination]'));
417
+ if (existing.length) {
418
+ const selected = position === 'both' ? existing : [existing.find((item) => item.getAttribute('data-position') === position) || existing[0]];
419
+ if (this._manageExternalVisibility) existing.forEach((container) => {
420
+ if (!this._externalVisibility.has(container)) this._externalVisibility.set(container, container.hidden);
421
+ container.hidden = !selected.includes(container);
422
+ });
423
+ return selected;
330
424
  }
331
425
 
332
426
  const positions = position === 'both' ? ['top', 'bottom'] : [position];