vgapp 1.5.5 → 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.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Описание: адаптер общего Responsive для представления VGTable.
3
+ * Возможности: отбор безопасных параметров пагинации и события таблицы; границы, наследование и подписки делегируются Responsive.
4
+ */
5
+ import EventHandler from "../../../utils/js/dom/event";
6
+ import {Responsive} from "../../../utils/js/components/responsive";
7
+
8
+ const object = (value) => value && typeof value === 'object' && !Array.isArray(value);
9
+
10
+ // Только представление: page/per, callbacks, storage и параметры запросов не принимаются.
11
+ const paginationProfile = (value) => {
12
+ const result = {};
13
+ if (!object(value)) return result;
14
+ if (value.maxButtons === null || (Number.isInteger(value.maxButtons) && value.maxButtons >= 3)) result.maxButtons = value.maxButtons;
15
+ if (['left', 'center', 'right', 'between'].includes(value.align)) result.align = value.align;
16
+ if (['top', 'bottom', 'both'].includes(value.position)) result.position = value.position;
17
+ if (object(value.size)) {
18
+ result.size = {};
19
+ if (typeof value.size.enabled === 'boolean') result.size.enabled = value.size.enabled;
20
+ if (value.size.label === false || typeof value.size.label === 'string') result.size.label = value.size.label;
21
+ }
22
+ if (object(value.quick) && [true, false, 'auto'].includes(value.quick.enabled)) result.quick = {enabled: value.quick.enabled};
23
+ return result;
24
+ };
25
+
26
+ class _responsive {
27
+ constructor(table, options, onChange) {
28
+ this._table = table;
29
+ this._onChange = onChange;
30
+ this._responsive = new Responsive({breakpoints: options.breakpoints, window: table.ownerDocument.defaultView});
31
+ this._profiles = Object.fromEntries(Object.keys(this._responsive.breakpoints).map((name) => [name, paginationProfile(options[name]?.pagination)]));
32
+ this._breakpoint = null;
33
+ this._pagination = {};
34
+ this._width = 0;
35
+ this._valid = this._responsive.isValid();
36
+ }
37
+
38
+ init() {
39
+ if (!this._valid) {
40
+ console.warn('VGTable: responsive.breakpoints must start at xs: 0 and increase through xxl.');
41
+ return this;
42
+ }
43
+ this.refresh();
44
+ this._responsive.subscribe(() => this.refresh());
45
+ return this;
46
+ }
47
+
48
+ refresh(force = false) {
49
+ if (!this._valid || !this._responsive) return this.getState();
50
+ this._width = this._responsive.viewport().width;
51
+ const breakpoint = this._responsive.getBreakpointKey(this._width);
52
+ if (!force && breakpoint === this._breakpoint) return this.getState();
53
+ const previous = this._breakpoint;
54
+ this._breakpoint = breakpoint;
55
+ this._pagination = this._responsive.resolve(this._profiles, {}, this._width);
56
+ this._onChange();
57
+ if (previous !== null && previous !== breakpoint) {
58
+ EventHandler.trigger(this._table, 'responsivechange.vg.table', {...this.getState(), previous});
59
+ }
60
+ return this.getState();
61
+ }
62
+
63
+ getState() {
64
+ return {
65
+ breakpoint: this._breakpoint,
66
+ width: this._responsive?.viewport().width ?? this._width,
67
+ reason: this._valid ? null : 'invalid-breakpoints',
68
+ pagination: {...this._pagination, size: {...this._pagination.size}, quick: {...this._pagination.quick}},
69
+ };
70
+ }
71
+
72
+ dispose() {
73
+ this._width = this._responsive?.viewport().width ?? this._width;
74
+ this._responsive?.dispose();
75
+ this._responsive = null;
76
+ }
77
+ }
78
+
79
+ export default _responsive;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Описание: основной модуль базовых таблиц VGTable.
3
- * Возможности: i18n, wrapper/container, состояния и URL state, local/remote, sticky, управление колонками и строками, сортировка, дерево, пагинация, выбор и panning.
3
+ * Возможности: i18n, wrapper/container, состояния и URL state, local/remote, sticky, колонки и строки, сортировка, дерево, responsive-пагинация, выбор и panning.
4
4
  */
5
5
  import BaseModule from "../../base-module";
6
6
  import {mergeDeepObject} from "../../../utils/js/functions";
@@ -9,7 +9,8 @@ import Selectors from "../../../utils/js/dom/selectors";
9
9
  import _sorting from "./_sorting.js";
10
10
  import _panning from "./_panning.js";
11
11
  import _selection from "./_selection.js";
12
- import _pagination from "./_pagination.js";
12
+ import _pagination from "./_pagination.js";
13
+ import _responsive from "./_responsive.js";
13
14
  import _expandable from "./_expandable.js";
14
15
  import _stickyHeader from "./_sticky-header.js";
15
16
  import _fixedColumns from "./_fixed-columns.js";
@@ -63,7 +64,8 @@ class VGTable extends BaseModule {
63
64
  this._panning = null;
64
65
  this._selection = null;
65
66
  this._expandable = null;
66
- this._pagination = null;
67
+ this._pagination = null;
68
+ this._responsive = null;
67
69
  this._filters = null;
68
70
  this._search = null;
69
71
  this._skeleton = null;
@@ -187,9 +189,17 @@ class VGTable extends BaseModule {
187
189
  this._expandable.init();
188
190
  }
189
191
 
190
- // Включаем локальную или серверную пагинацию
191
- if (!this._pagination && this._params.pagination.enabled === true) {
192
- this._pagination = new _pagination(this._element, Object.assign({}, this._params.pagination, {
192
+ if (!this._responsive && this._params.responsive?.enabled === true) {
193
+ this._responsive = new _responsive(this._element, this._params.responsive, () => {
194
+ this._pagination?.updatePresentation(this._paginationOptions());
195
+ });
196
+ this._responsive.init();
197
+ }
198
+
199
+ // Включаем локальную или серверную пагинацию
200
+ if (!this._pagination && this._params.pagination.enabled === true) {
201
+ this._pagination = new _pagination(this._element, Object.assign({}, this._paginationOptions(), {
202
+ responsive: this._responsive?.getState().reason === null,
193
203
  remote: this._isRemote,
194
204
  onChange: (state) => this._handlePaginationChange(state),
195
205
  }));
@@ -300,15 +310,27 @@ class VGTable extends BaseModule {
300
310
  this._normalizeColumnsDataOptions();
301
311
  this._normalizeRowReorderDataOptions();
302
312
  if (this._remote) this._remote._options.labels = this._dictionary.remote || {};
303
- this._pagination?.refresh?.();
313
+ this._pagination?.updatePresentation(this._paginationOptions());
304
314
  this._expandable?.refresh?.();
305
315
  this._columns?.refresh?.();
306
316
  this._rowReorder?.refresh?.();
307
317
  const state = this._states?.getState?.();
308
318
  if (state) this._states.render(state.type);
309
319
  EventHandler.trigger(this._element, 'localechange.vg.table', {locale: normalized});
310
- return normalized;
311
- }
320
+ return normalized;
321
+ }
322
+
323
+ /** Текущий брейкпоинт и накопленные переопределения; null при выключенном responsive. */
324
+ getResponsiveState() { return this._responsive?.getState() || null; }
325
+
326
+ /** Принудительно обновляет только адаптивное представление, без загрузки данных. */
327
+ refreshResponsive() { return this._responsive?.refresh(true) || null; }
328
+
329
+ _paginationOptions() {
330
+ const base = this._params.pagination;
331
+ const profile = this._responsive?.getState().pagination || {};
332
+ return {...base, ...profile, size: {...base.size, ...profile.size}, quick: {...base.quick, ...profile.quick}};
333
+ }
312
334
 
313
335
  _mergeLocale(target, source) {
314
336
  if (!target || !source || typeof source !== 'object') return target;
@@ -524,7 +546,11 @@ class VGTable extends BaseModule {
524
546
  assign('ellipsis', (value) => { pagination.ellipsis = boolean(value); });
525
547
  assign('ellipsis-hover', (value) => { pagination.ellipsisHover = boolean(value); });
526
548
  assign('ellipsis-after', (value) => { pagination.threshold = number(value, pagination.threshold); });
527
- assign('max-visible-pages', (value) => { pagination.visible = number(value, pagination.visible); });
549
+ assign('max-visible-pages', (value) => { pagination.visible = number(value, pagination.visible); });
550
+ assign('max-buttons', (value) => {
551
+ const parsed = Number(value);
552
+ pagination.maxButtons = Number.isInteger(parsed) && parsed >= 3 ? parsed : null;
553
+ });
528
554
  assign('show-per-page', (value) => { pagination.size.enabled = boolean(value); });
529
555
  assign('show-per-page-label', (value) => { pagination.size.label = boolean(value) ? 'Строк на странице' : false; });
530
556
  assign('per-page-label', (value) => { pagination.size.label = value; });
@@ -1436,7 +1462,8 @@ class VGTable extends BaseModule {
1436
1462
  /**
1437
1463
  * Очистка ресурсов
1438
1464
  */
1439
- dispose() {
1465
+ dispose() {
1466
+ this._responsive?.dispose();
1440
1467
  if (this._remote) this._remote.dispose();
1441
1468
  if (this._skeleton) this._skeleton.dispose();
1442
1469
  if (this._urlState) this._urlState.dispose();
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Описание: общий сервис брейкпоинтов и адаптивных настроек VGApp.
3
+ * Возможности: xs–xxl, глобальные и локальные границы, наследование профилей, подписки, viewport и эвристики устройства/touch.
4
+ */
5
+ import {normalizeData} from "../functions";
6
+
7
+ export const DEFAULT_BREAKPOINTS = Object.freeze({xs: 0, sm: 576, md: 768, lg: 992, xl: 1200, xxl: 1400});
8
+
9
+ const plainObject = (value) => {
10
+ if (value === null || typeof value !== 'object') return false;
11
+ const prototype = Object.getPrototypeOf(value);
12
+ return prototype === null || Object.getPrototypeOf(prototype) === null;
13
+ };
14
+ const entries = (value) => Object.entries(value).filter(([key]) => !['__proto__', 'constructor', 'prototype'].includes(key));
15
+ const clone = (value) => Array.isArray(value) ? value.map(clone)
16
+ : plainObject(value) ? Object.fromEntries(entries(value).map(([key, item]) => [key, clone(item)])) : value;
17
+
18
+ // В профилях массив заменяется целиком, а не конкатенируется как в mergeDeepObject.
19
+ const merge = (base, profile) => {
20
+ const result = plainObject(base) ? clone(base) : {};
21
+ if (plainObject(profile)) entries(profile).forEach(([key, value]) => {
22
+ result[key] = plainObject(value) ? merge(result[key], value) : clone(value);
23
+ });
24
+ return result;
25
+ };
26
+
27
+ export class Responsive {
28
+ constructor(options = {}) {
29
+ this._window = options.window === undefined ? (typeof window === 'undefined' ? null : window) : options.window;
30
+ const globalPoints = this._window?.Breakpoints ?? this._window?.breakpoints;
31
+ const globalMap = normalizeData(globalPoints ?? {});
32
+ const localMap = normalizeData(options.breakpoints ?? {});
33
+ const sourcesValid = plainObject(globalMap) && plainObject(localMap);
34
+ const combined = {...DEFAULT_BREAKPOINTS, ...(plainObject(globalMap) ? globalMap : {}), ...(plainObject(localMap) ? localMap : {})};
35
+ this._breakpoints = Object.freeze(Object.fromEntries(entries(combined).map(([key, value]) => [key,
36
+ typeof value === 'string' && value.trim() !== '' ? Number(value) : value,
37
+ ])));
38
+ const standardNames = Object.keys(DEFAULT_BREAKPOINTS);
39
+ const widths = Object.values(this._breakpoints);
40
+ this._valid = sourcesValid && widths.every((width) => Number.isFinite(width) && width >= 0)
41
+ && new Set(widths).size === widths.length
42
+ && standardNames.every((name, index) => index === 0 ? this._breakpoints[name] === 0
43
+ : this._breakpoints[name] > this._breakpoints[standardNames[index - 1]]);
44
+ this._keys = this._valid ? Object.keys(this._breakpoints).sort((a, b) => this._breakpoints[a] - this._breakpoints[b]) : [];
45
+ this._subscribers = new Set();
46
+ this._previous = null;
47
+ this._boundResize = () => {
48
+ const state = this.getState();
49
+ if (state.breakpoint === this._previous) return;
50
+ const previous = this._previous;
51
+ this._previous = state.breakpoint;
52
+ Array.from(this._subscribers).forEach((subscriber) => {
53
+ if (!this._subscribers.has(subscriber)) return;
54
+ try { subscriber({...this.getState(), previous}); }
55
+ catch (error) {
56
+ if (typeof this._window?.reportError === 'function') this._window.reportError(error);
57
+ else console.error(error);
58
+ }
59
+ });
60
+ };
61
+ }
62
+
63
+ /** Копия границ; внешнее изменение не влияет на экземпляр. */
64
+ get breakpoints() { return {...this._breakpoints}; }
65
+ isValid() { return this._valid; }
66
+
67
+ /** Без аргумента — карта; имя — min-width; число (включая 0) — имя диапазона. */
68
+ breakpoint(point) {
69
+ if (point === undefined) return this.breakpoints;
70
+ if (typeof point === 'number') return this.getBreakpointKey(point);
71
+ return this.checkBreakpoint(point) ? this._breakpoints[point] : null;
72
+ }
73
+
74
+ checkBreakpoint(point) {
75
+ return this._valid && typeof point === 'string' && Object.prototype.hasOwnProperty.call(this._breakpoints, point);
76
+ }
77
+
78
+ breakpointDown(point) { return this.checkBreakpoint(point) && this.viewport().width < this._breakpoints[point]; }
79
+ breakpointUp(point) { return this.checkBreakpoint(point) && this.viewport().width >= this._breakpoints[point]; }
80
+
81
+ /** Полуоткрытый диапазон [start, end), без пересечения соседних диапазонов. */
82
+ breakpointBetween(start, end) {
83
+ const width = this.viewport().width;
84
+ return this.checkBreakpoint(start) && this.checkBreakpoint(end)
85
+ && this._breakpoints[start] < this._breakpoints[end]
86
+ && width >= this._breakpoints[start] && width < this._breakpoints[end];
87
+ }
88
+
89
+ getActiveBreakpoints(width = this.viewport().width) {
90
+ if (!this._valid || !Number.isFinite(width) || width < 0) return [];
91
+ return this._keys.filter((key) => width >= this._breakpoints[key]);
92
+ }
93
+
94
+ getBreakpointKey(width = this.viewport().width) { return this.getActiveBreakpoints(width).at(-1) ?? null; }
95
+
96
+ /** База + все достигнутые профили; вложенные объекты объединяются, массивы заменяются. */
97
+ resolve(profiles = {}, base = {}, width = this.viewport().width) {
98
+ return this.getActiveBreakpoints(width).reduce((result, key) => merge(result, profiles?.[key]), merge({}, base));
99
+ }
100
+
101
+ getState() {
102
+ const viewport = this.viewport();
103
+ return {...viewport, breakpoint: this.getBreakpointKey(viewport.width), active: this.getActiveBreakpoints(viewport.width),
104
+ reason: this._valid ? null : 'invalid-breakpoints'};
105
+ }
106
+
107
+ /** Уведомляет только о смене диапазона; возвращает функцию отписки. */
108
+ subscribe(callback, {immediate = false} = {}) {
109
+ if (typeof callback !== 'function') throw new TypeError('Responsive.subscribe expects a function.');
110
+ if (!this._valid) return () => {};
111
+ const subscriber = (state) => callback(state);
112
+ if (this._subscribers.size === 0) {
113
+ this._previous = this.getBreakpointKey();
114
+ this._window?.addEventListener('resize', this._boundResize);
115
+ }
116
+ this._subscribers.add(subscriber);
117
+ const unsubscribe = () => {
118
+ this._subscribers.delete(subscriber);
119
+ if (this._subscribers.size === 0) this._window?.removeEventListener('resize', this._boundResize);
120
+ };
121
+ if (immediate) {
122
+ try { callback({...this.getState(), previous: null}); }
123
+ catch (error) { unsubscribe(); throw error; }
124
+ }
125
+ return unsubscribe;
126
+ }
127
+
128
+ dispose() {
129
+ this._window?.removeEventListener('resize', this._boundResize);
130
+ this._subscribers.clear();
131
+ }
132
+
133
+ viewport() {
134
+ return {width: this._window?.innerWidth ?? 0, height: this._window?.innerHeight ?? 0};
135
+ }
136
+
137
+ /** Возможность touch-ввода, не классификация телефона или планшета. */
138
+ detectTouchDevice() {
139
+ return !!this._window && (Number(this._window.navigator?.maxTouchPoints) > 0 || 'ontouchstart' in this._window);
140
+ }
141
+
142
+ /** Эвристики устройства не используются при выборе responsive-профилей. */
143
+ isMobileDevice() {
144
+ if (!this._window) return false;
145
+ const userAgent = this._window.navigator?.userAgent || '';
146
+ return /Android|iPhone|iPad|iPod/i.test(userAgent)
147
+ || (this.detectTouchDevice() && this.viewport().width < 768 && this._window.devicePixelRatio >= 2);
148
+ }
149
+
150
+ isTabletDevice() {
151
+ if (!this._window) return false;
152
+ const navigator = this._window.navigator || {};
153
+ const userAgent = (navigator.userAgent || '').toLowerCase();
154
+ const {width, height} = this.viewport();
155
+ const short = Math.min(width, height);
156
+ const long = Math.max(width, height);
157
+ return /ipad/.test(userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
158
+ || (/android/.test(userAgent) && !/mobile/.test(userAgent) && long > 800)
159
+ || (this.detectTouchDevice() && short >= 600 && short <= 1200 && long >= 800 && long <= 1600);
160
+ }
161
+
162
+ detectDevice() {
163
+ return this.isTabletDevice() ? 'tablet' : this.isMobileDevice() ? 'mobile' : 'desktop';
164
+ }
165
+ }