griffincss-ui 0.16.0

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,661 @@
1
+ /*!
2
+ * Griffincss UI — Runtime v0.16.0
3
+ * Опциональный рантайм пакета компонентов. Делает ровно то, чего платформа
4
+ * не даёт вовсе; всё, что умеют <details>, <dialog> и Popover API, остаётся
5
+ * за ними. Без этого файла компоненты работают — просто без перечисленного.
6
+ *
7
+ * Здесь четыре вещи:
8
+ *
9
+ * 1. Закрытие модального окна и выдвижной панели щелчком по подложке.
10
+ * <dialog> закрывается по Esc сам, но щелчок мимо окна ему безразличен,
11
+ * а ждут его почти всегда. Включается атрибутом на самом окне:
12
+ *
13
+ * <dialog class="gr-modal" data-gr-overlay-close>…</dialog>
14
+ *
15
+ * Так поведение остаётся решением автора страницы: у окна с формой,
16
+ * где случайный щелчок мимо потерял бы введённое, атрибута просто нет.
17
+ *
18
+ * 2. Закрытие сообщения крестиком — [data-gr-dismiss] на кнопке внутри
19
+ * .gr-alert, .gr-toast или .gr-tag. Убирает узел из документа, а не
20
+ * прячет: закрытое сообщение не должно оставаться в порядке обхода.
21
+ *
22
+ * 3. Тосты — Griffincss.ui.toast(text, options): вставка в живую область
23
+ * и авто-скрытие. Единственный компонент библиотеки, у которого нет
24
+ * статического применения.
25
+ *
26
+ * 4. Клавиатура вкладок — стрелки, Home/End, roving tabindex и aria-*
27
+ * для разметки с [data-gr-tabs]. Без скрипта та же разметка остаётся
28
+ * списком ссылок-якорей и секциями подряд.
29
+ *
30
+ * Всё, что умеют <details>, <dialog> и Popover API, остаётся за ними.
31
+ */
32
+ (function (factory) {
33
+ 'use strict';
34
+
35
+ var api = factory();
36
+
37
+ // Node (тесты) — модуль; браузер — глобальный объект и автостарт.
38
+ if (typeof module === 'object' && module.exports) {
39
+ module.exports = api;
40
+ }
41
+
42
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') {
43
+ // Слияние, а не присваивание: рантайм раскладок, тема и компоненты
44
+ // делят один глобал, и порядок тегов <script> не должен ничего значить.
45
+ if (!window.Griffincss) window.Griffincss = {};
46
+
47
+ window.Griffincss.ui = api;
48
+ api._autoStart();
49
+ }
50
+ })(function () {
51
+ 'use strict';
52
+
53
+ var VERSION = '0.16.0';
54
+
55
+ var OVERLAY_ATTR = 'data-gr-overlay-close';
56
+
57
+ // Крестик закрытия и то, что он закрывает. Список закрыт намеренно:
58
+ // «убрать ближайшего предка» без ограничения снесло бы полстраницы,
59
+ // если крестик поставили не туда.
60
+ var DISMISS_ATTR = 'data-gr-dismiss';
61
+ var DISMISSIBLE = '.gr-toast,.gr-alert,.gr-tag';
62
+
63
+ // Тосты.
64
+ var TOAST_POSITIONS = ['top-start', 'top-end', 'bottom-start', 'bottom-end'];
65
+ var TOAST_STATUSES = ['info', 'success', 'warning', 'danger'];
66
+ var TOAST_TIMEOUT = 5000;
67
+
68
+ // Запас на переход ухода: --gr-transition — 0.2s, и узел убирается,
69
+ // когда движение заведомо кончилось. Ждать transitionend нельзя —
70
+ // при prefers-reduced-motion и на скрытой вкладке событие может
71
+ // не прийти вовсе, и тост остался бы в документе навсегда.
72
+ var TOAST_LEAVE = 300;
73
+
74
+ var TABS_ATTR = 'data-gr-tabs';
75
+
76
+ // Причина закрытия попадает в dialog.returnValue: обработчик close
77
+ // на странице отличит щелчок мимо от кнопки «Сохранить».
78
+ var OVERLAY_REASON = 'overlay';
79
+
80
+ var started = false;
81
+
82
+ // Цель нажатия. Щелчок — это пара «нажал» и «отпустил», и закрывать окно
83
+ // нужно, только если обе половины пришлись на подложку: выделение текста,
84
+ // начатое в окне и законченное за его краем, щелчком по подложке не является.
85
+ var pressed = null;
86
+
87
+ function isDialog(node) {
88
+ return !!node && node.tagName === 'DIALOG' && typeof node.close === 'function';
89
+ }
90
+
91
+ // Щелчок по подложке приходит на сам <dialog>: подложка — его псевдоэлемент,
92
+ // собственной цели у неё нет. Отличить её от полей окна можно только
93
+ // по координатам.
94
+ function outsideBox(dialog, event) {
95
+ if (typeof dialog.getBoundingClientRect !== 'function') return false;
96
+
97
+ var rect = dialog.getBoundingClientRect();
98
+
99
+ // Нулевой прямоугольник — окно не отрисовано (скрытая вкладка,
100
+ // печать, тест без раскладки). Закрывать по такому нельзя.
101
+ if (!rect || (!rect.width && !rect.height)) return false;
102
+
103
+ if (typeof event.clientX !== 'number' || typeof event.clientY !== 'number') return false;
104
+
105
+ // Щелчок с клавиатуры (Enter на кнопке) приходит с координатами 0,0
106
+ // и не является щелчком по подложке.
107
+ if (event.clientX === 0 && event.clientY === 0) return false;
108
+
109
+ return (
110
+ event.clientX < rect.left ||
111
+ event.clientX > rect.right ||
112
+ event.clientY < rect.top ||
113
+ event.clientY > rect.bottom
114
+ );
115
+ }
116
+
117
+ function onPointerDown(event) {
118
+ pressed = event.target;
119
+ }
120
+
121
+ function onClick(event) {
122
+ // Три разных щелчка приходят одним событием, и порядок разбора значим:
123
+ // крестик внутри окна не должен считаться щелчком по подложке,
124
+ // а вкладка-ссылка не должна уводить страницу к якорю.
125
+ var trigger = closest(event.target, '[' + DISMISS_ATTR + ']');
126
+
127
+ if (trigger) {
128
+ dismissFrom(trigger);
129
+ return;
130
+ }
131
+
132
+ if (onTabClick(event)) return;
133
+
134
+ var dialog = event.target;
135
+
136
+ if (!isDialog(dialog)) return;
137
+ if (!dialog.hasAttribute(OVERLAY_ATTR)) return;
138
+ if (pressed !== null && pressed !== dialog) return;
139
+ if (!outsideBox(dialog, event)) return;
140
+
141
+ dialog.close(OVERLAY_REASON);
142
+ }
143
+
144
+ // --- Общее ------------------------------------------------------------------
145
+
146
+ // Ближайший предок, включая сам узел. Element.closest есть везде, где есть
147
+ // <dialog>, но в мок-DOM тестов его может не быть — отсюда запасной обход.
148
+ function closest(node, selector) {
149
+ if (!node) return null;
150
+
151
+ if (typeof node.closest === 'function') return node.closest(selector);
152
+
153
+ var current = node;
154
+
155
+ while (current) {
156
+ if (typeof current.matches === 'function' && current.matches(selector)) return current;
157
+ current = current.parentNode;
158
+ }
159
+
160
+ return null;
161
+ }
162
+
163
+ function contains(root, node) {
164
+ var current = node;
165
+
166
+ while (current) {
167
+ if (current === root) return true;
168
+ current = current.parentNode;
169
+ }
170
+
171
+ return false;
172
+ }
173
+
174
+ function warn(message) {
175
+ if (typeof console !== 'undefined' && console && typeof console.warn === 'function') {
176
+ console.warn('Griffincss UI: ' + message);
177
+ }
178
+ }
179
+
180
+ function element(tag, className) {
181
+ var node = document.createElement(tag);
182
+
183
+ if (className) {
184
+ var names = className.split(' ');
185
+
186
+ for (var i = 0; i < names.length; i++) node.classList.add(names[i]);
187
+ }
188
+
189
+ return node;
190
+ }
191
+
192
+ // --- Закрытие крестиком -----------------------------------------------------
193
+
194
+ // Цель задаётся значением атрибута, если она не предок кнопки:
195
+ //
196
+ // <button data-gr-dismiss="#banner">×</button>
197
+ //
198
+ // Пустое значение — обычный случай: убрать ближайшее сообщение,
199
+ // внутри которого стоит сам крестик.
200
+ function dismissFrom(trigger) {
201
+ var selector = trigger.getAttribute(DISMISS_ATTR);
202
+ var target = selector
203
+ ? (typeof document.querySelector === 'function' ? document.querySelector(selector) : null)
204
+ : closest(trigger, DISMISSIBLE);
205
+
206
+ if (!target) {
207
+ warn('крестику нечего закрывать: ни цели в data-gr-dismiss, ни сообщения вокруг');
208
+ return;
209
+ }
210
+
211
+ dismiss(target);
212
+ }
213
+
214
+ // Тост уходит движением — у него для этого есть класс и переход;
215
+ // остальное убирается сразу. Прятать вместо удаления нельзя: скрытое
216
+ // классом сообщение осталось бы в порядке обхода и в озвучке.
217
+ function dismiss(node) {
218
+ if (!node) return false;
219
+
220
+ if (node.classList && node.classList.contains('gr-toast')) {
221
+ leave(node);
222
+ return true;
223
+ }
224
+
225
+ if (typeof node.remove === 'function') node.remove();
226
+
227
+ return true;
228
+ }
229
+
230
+ // --- Тосты ------------------------------------------------------------------
231
+
232
+ // Живая область под тосты. Ищется среди уже стоящих на странице: регион,
233
+ // объявленный в разметке, — предпочтительный случай, потому что программа
234
+ // чтения с экрана замечает вставку только в область, существовавшую
235
+ // до неё. Созданный на лету регион работает в современных движках,
236
+ // но остаётся запасным путём.
237
+ function ensureRegion(position, urgent) {
238
+ var live = urgent ? 'assertive' : 'polite';
239
+ var wanted = 'gr-toast-region-' + position;
240
+ var existing = document.querySelectorAll('.gr-toast-region');
241
+
242
+ for (var i = 0; i < existing.length; i++) {
243
+ var region = existing[i];
244
+
245
+ if (region.classList.contains(wanted) && region.getAttribute('aria-live') === live) {
246
+ return region;
247
+ }
248
+ }
249
+
250
+ var created = element('div', 'gr-toast-region ' + wanted);
251
+
252
+ created.setAttribute('aria-live', live);
253
+ created.setAttribute('role', urgent ? 'alert' : 'status');
254
+ document.body.appendChild(created);
255
+
256
+ return created;
257
+ }
258
+
259
+ function buildToast(text, options, status) {
260
+ var node = element('div', status ? 'gr-toast gr-toast-' + status : 'gr-toast');
261
+
262
+ if (options.icon) {
263
+ var icon = element('span', 'gr-toast-icon');
264
+
265
+ icon.setAttribute('aria-hidden', 'true');
266
+ icon.textContent = options.icon;
267
+ node.appendChild(icon);
268
+ }
269
+
270
+ var body = element('div', 'gr-toast-body');
271
+
272
+ if (options.title) {
273
+ var title = element('strong', 'gr-toast-title');
274
+
275
+ title.textContent = options.title;
276
+ body.appendChild(title);
277
+ }
278
+
279
+ var line = element('span');
280
+
281
+ line.textContent = text === undefined || text === null ? '' : String(text);
282
+ body.appendChild(line);
283
+ node.appendChild(body);
284
+
285
+ if (options.dismissible !== false) {
286
+ var close = element('button', 'gr-btn gr-btn-icon gr-btn-ghost gr-btn-sm');
287
+
288
+ close.setAttribute('type', 'button');
289
+ close.setAttribute(DISMISS_ATTR, '');
290
+ close.setAttribute('aria-label', options.closeLabel || 'Закрыть');
291
+ close.textContent = '×';
292
+ node.appendChild(close);
293
+ }
294
+
295
+ return node;
296
+ }
297
+
298
+ // Griffincss.ui.toast('Черновик сохранён', { status: 'success', title: 'Готово' })
299
+ //
300
+ // timeout: 0 — тост без таймера, только с крестиком. Так показывают то,
301
+ // что нельзя пропустить: ошибку отправки формы или предложение отменить
302
+ // необратимое действие.
303
+ function toast(text, options) {
304
+ options = options || {};
305
+
306
+ var status = indexOf(TOAST_STATUSES, options.status) === -1 ? '' : options.status;
307
+ var position = indexOf(TOAST_POSITIONS, options.position) === -1 ? 'bottom-end' : options.position;
308
+
309
+ // Срочность по умолчанию выводится из статуса: ошибка перебивает
310
+ // текущее чтение, остальное ждёт своей очереди.
311
+ var urgent = options.assertive === undefined ? status === 'danger' : !!options.assertive;
312
+
313
+ if (status && !options.title) {
314
+ warn('тост со статусом "' + status + '" без title: статус остался одним цветом, '
315
+ + 'а он не переживает чёрно-белую печать и дальтонизм');
316
+ }
317
+
318
+ var node = buildToast(text, options, status);
319
+
320
+ ensureRegion(position, urgent).appendChild(node);
321
+
322
+ var timeout = options.timeout === undefined ? TOAST_TIMEOUT : Number(options.timeout);
323
+
324
+ if (timeout > 0) startTimer(node, timeout);
325
+
326
+ return node;
327
+ }
328
+
329
+ function indexOf(list, value) {
330
+ for (var i = 0; i < list.length; i++) {
331
+ if (list[i] === value) return i;
332
+ }
333
+
334
+ return -1;
335
+ }
336
+
337
+ // Таймер живёт на самом узле: тостов на экране бывает несколько,
338
+ // и общего состояния у них нет.
339
+ function startTimer(node, rest) {
340
+ node.grToastRest = rest;
341
+ node.grToastFrom = now();
342
+ node.grToastTimer = setTimeout(function () { leave(node); }, rest);
343
+ }
344
+
345
+ function stopTimer(node) {
346
+ if (node.grToastTimer) {
347
+ clearTimeout(node.grToastTimer);
348
+ node.grToastTimer = null;
349
+ }
350
+ }
351
+
352
+ function now() {
353
+ return typeof Date !== 'undefined' && Date.now ? Date.now() : 0;
354
+ }
355
+
356
+ // Пауза под указателем и под фокусом. Сообщение, исчезающее ровно тогда,
357
+ // когда его начали читать или потянулись к ссылке внутри, — не удобство,
358
+ // а ловушка. Остаток дочитывается, а не начинается заново: иначе тост
359
+ // висел бы на экране, пока по нему возят мышью.
360
+ function pauseToast(node) {
361
+ if (!node || node.grToastLeaving || !node.grToastTimer) return;
362
+
363
+ stopTimer(node);
364
+ node.grToastRest = Math.max(0, node.grToastRest - (now() - node.grToastFrom));
365
+ }
366
+
367
+ function resumeToast(node) {
368
+ if (!node || node.grToastLeaving || node.grToastTimer) return;
369
+ if (!node.grToastRest) return;
370
+
371
+ startTimer(node, node.grToastRest);
372
+ }
373
+
374
+ function leave(node) {
375
+ if (node.grToastLeaving) return;
376
+
377
+ node.grToastLeaving = true;
378
+ stopTimer(node);
379
+
380
+ if (node.classList) node.classList.add('gr-toast-leaving');
381
+
382
+ setTimeout(function () {
383
+ if (typeof node.remove === 'function') node.remove();
384
+ }, TOAST_LEAVE);
385
+ }
386
+
387
+ // pointerover / pointerout, а не enter / leave: эти всплывают, и хватает
388
+ // одного слушателя на документ. Переход между частями одного тоста —
389
+ // не уход: relatedTarget внутри того же узла.
390
+ function onPointerOver(event) {
391
+ pauseToast(closest(event.target, '.gr-toast'));
392
+ }
393
+
394
+ function onPointerOut(event) {
395
+ var node = closest(event.target, '.gr-toast');
396
+
397
+ if (!node || contains(node, event.relatedTarget)) return;
398
+
399
+ resumeToast(node);
400
+ }
401
+
402
+ function onFocusIn(event) {
403
+ pauseToast(closest(event.target, '.gr-toast'));
404
+ }
405
+
406
+ function onFocusOut(event) {
407
+ var node = closest(event.target, '.gr-toast');
408
+
409
+ if (!node || contains(node, event.relatedTarget)) return;
410
+
411
+ resumeToast(node);
412
+ }
413
+
414
+ // --- Вкладки ----------------------------------------------------------------
415
+
416
+ var tabsSeq = 0;
417
+
418
+ // Разметка до скрипта — список ссылок-якорей и секции подряд: страница
419
+ // читается и печатается целиком. Скрипт добавляет роли, прячет неактивные
420
+ // панели атрибутом hidden и заводит roving tabindex, при котором Tab
421
+ // выводит из ряда вкладок, а не идёт по всем его пунктам.
422
+ function initTabs(root) {
423
+ var scope = root || document;
424
+ var groups = scope.querySelectorAll('[' + TABS_ATTR + ']');
425
+
426
+ for (var i = 0; i < groups.length; i++) initGroup(groups[i]);
427
+
428
+ return publicApi;
429
+ }
430
+
431
+ function initGroup(group) {
432
+ var tabs = group.querySelectorAll('.gr-tab');
433
+
434
+ if (!tabs.length) return;
435
+
436
+ var lists = group.querySelectorAll('.gr-tablist');
437
+
438
+ if (lists.length) lists[0].setAttribute('role', 'tablist');
439
+
440
+ var selected = 0;
441
+
442
+ for (var i = 0; i < tabs.length; i++) {
443
+ var tab = tabs[i];
444
+ var panel = panelOf(tab);
445
+
446
+ tab.setAttribute('role', 'tab');
447
+
448
+ if (!tab.getAttribute('id')) tab.setAttribute('id', 'gr-tab-' + (++tabsSeq));
449
+
450
+ if (panel) {
451
+ tab.setAttribute('aria-controls', panel.getAttribute('id'));
452
+ panel.setAttribute('role', 'tabpanel');
453
+ panel.setAttribute('aria-labelledby', tab.getAttribute('id'));
454
+
455
+ // Панель получает фокус после стрелок: иначе клавиатура из ряда
456
+ // вкладок уходила бы в конец страницы, мимо только что открытого
457
+ // содержимого.
458
+ panel.setAttribute('tabindex', '0');
459
+ }
460
+
461
+ // Активная вкладка ищется тем же признаком, которым её отметили
462
+ // в разметке без скрипта.
463
+ if (tab.hasAttribute('aria-current') || tab.getAttribute('aria-selected') === 'true') {
464
+ selected = i;
465
+ }
466
+ }
467
+
468
+ select(tabs, selected, false);
469
+ }
470
+
471
+ // Панель — по href="#id" у ссылки или по aria-controls у кнопки.
472
+ function panelOf(tab) {
473
+ var href = tab.getAttribute('href');
474
+ var id = tab.getAttribute('aria-controls');
475
+
476
+ if (!id && href && href.charAt(0) === '#') id = href.slice(1);
477
+ if (!id) return null;
478
+
479
+ return document.getElementById(id);
480
+ }
481
+
482
+ function select(tabs, index, focus) {
483
+ for (var i = 0; i < tabs.length; i++) {
484
+ var tab = tabs[i];
485
+ var on = i === index;
486
+ var panel = panelOf(tab);
487
+
488
+ tab.setAttribute('aria-selected', on ? 'true' : 'false');
489
+ tab.setAttribute('tabindex', on ? '0' : '-1');
490
+
491
+ // aria-current — признак варианта без скрипта. Оставь его скрипт
492
+ // на месте, программа чтения с экрана объявила бы вкладку и текущей
493
+ // страницей, и выбранной вкладкой сразу.
494
+ tab.removeAttribute('aria-current');
495
+
496
+ if (panel) {
497
+ if (on) panel.removeAttribute('hidden');
498
+ else panel.setAttribute('hidden', '');
499
+ }
500
+ }
501
+
502
+ if (focus && typeof tabs[index].focus === 'function') tabs[index].focus();
503
+ }
504
+
505
+ function disabled(tab) {
506
+ return tab.getAttribute('aria-disabled') === 'true' || tab.hasAttribute('disabled');
507
+ }
508
+
509
+ // Следующая доступная вкладка в заданном направлении, по кругу:
510
+ // ряд замкнут, как того требует WAI-ARIA для вкладок.
511
+ function step(tabs, from, delta) {
512
+ var index = from;
513
+
514
+ for (var i = 0; i < tabs.length; i++) {
515
+ index = (index + delta + tabs.length) % tabs.length;
516
+
517
+ if (!disabled(tabs[index])) return index;
518
+ }
519
+
520
+ return from;
521
+ }
522
+
523
+ function edge(tabs, delta) {
524
+ var index = delta > 0 ? -1 : tabs.length;
525
+
526
+ return step(tabs, index, delta);
527
+ }
528
+
529
+ function onKeyDown(event) {
530
+ var tab = closest(event.target, '.gr-tab');
531
+
532
+ if (!tab) return;
533
+
534
+ var group = closest(tab, '[' + TABS_ATTR + ']');
535
+
536
+ if (!group) return;
537
+
538
+ // Список читается заново: вкладки добавляют и убирают вместе
539
+ // с разметкой, и запомненный при инициализации массив устарел бы молча.
540
+ var tabs = group.querySelectorAll('.gr-tab');
541
+ var current = indexOfNode(tabs, tab);
542
+
543
+ if (current === -1) return;
544
+
545
+ var next = current;
546
+ var key = event.key;
547
+
548
+ // Обе оси: ряд вкладок бывает и горизонтальным, и — в чужой раскладке —
549
+ // поставленным в колонку.
550
+ if (key === 'ArrowRight' || key === 'ArrowDown') next = step(tabs, current, 1);
551
+ else if (key === 'ArrowLeft' || key === 'ArrowUp') next = step(tabs, current, -1);
552
+ else if (key === 'Home') next = edge(tabs, 1);
553
+ else if (key === 'End') next = edge(tabs, -1);
554
+ else return;
555
+
556
+ if (typeof event.preventDefault === 'function') event.preventDefault();
557
+
558
+ select(tabs, next, true);
559
+ }
560
+
561
+ // Щелчок по вкладке-ссылке переключает панель, а не уводит страницу
562
+ // к якорю: прыжок сдвинул бы ряд вкладок за верхний край экрана.
563
+ function onTabClick(event) {
564
+ var tab = closest(event.target, '.gr-tab');
565
+
566
+ if (!tab) return false;
567
+
568
+ var group = closest(tab, '[' + TABS_ATTR + ']');
569
+
570
+ if (!group) return false;
571
+ if (disabled(tab)) {
572
+ if (typeof event.preventDefault === 'function') event.preventDefault();
573
+ return true;
574
+ }
575
+
576
+ var tabs = group.querySelectorAll('.gr-tab');
577
+ var index = indexOfNode(tabs, tab);
578
+
579
+ if (index === -1) return false;
580
+
581
+ if (typeof event.preventDefault === 'function') event.preventDefault();
582
+ select(tabs, index, false);
583
+
584
+ return true;
585
+ }
586
+
587
+ function indexOfNode(list, node) {
588
+ for (var i = 0; i < list.length; i++) {
589
+ if (list[i] === node) return i;
590
+ }
591
+
592
+ return -1;
593
+ }
594
+
595
+ // Один делегированный обработчик на документ, а не по слушателю на окно:
596
+ // окна появляются и исчезают вместе с разметкой, и переподписываться
597
+ // на каждое пришлось бы вручную.
598
+ function start() {
599
+ if (started) return publicApi;
600
+
601
+ started = true;
602
+ document.addEventListener('pointerdown', onPointerDown, true);
603
+ document.addEventListener('click', onClick);
604
+ document.addEventListener('keydown', onKeyDown);
605
+
606
+ // pointerover / pointerout и focusin / focusout всплывают, в отличие
607
+ // от enter / leave и focus / blur: одного слушателя на документ хватает
608
+ // на все тосты сразу, включая ещё не созданные.
609
+ document.addEventListener('pointerover', onPointerOver);
610
+ document.addEventListener('pointerout', onPointerOut);
611
+ document.addEventListener('focusin', onFocusIn);
612
+ document.addEventListener('focusout', onFocusOut);
613
+
614
+ return publicApi;
615
+ }
616
+
617
+ function destroy() {
618
+ if (!started) return publicApi;
619
+
620
+ started = false;
621
+ pressed = null;
622
+ document.removeEventListener('pointerdown', onPointerDown, true);
623
+ document.removeEventListener('click', onClick);
624
+ document.removeEventListener('keydown', onKeyDown);
625
+ document.removeEventListener('pointerover', onPointerOver);
626
+ document.removeEventListener('pointerout', onPointerOut);
627
+ document.removeEventListener('focusin', onFocusIn);
628
+ document.removeEventListener('focusout', onFocusOut);
629
+
630
+ return publicApi;
631
+ }
632
+
633
+ // Автостарт: только в браузере и только если его не отключили атрибутом
634
+ // data-auto="false" на теге <script>.
635
+ function autoStart() {
636
+ try {
637
+ var script = document.currentScript;
638
+ if (script && script.getAttribute('data-auto') === 'false') return;
639
+ } catch (e) { /* currentScript недоступен */ }
640
+
641
+ if (document.readyState === 'loading') {
642
+ document.addEventListener('DOMContentLoaded', function () { start(); initTabs(); });
643
+ return;
644
+ }
645
+
646
+ start();
647
+ initTabs();
648
+ }
649
+
650
+ var publicApi = {
651
+ start: start,
652
+ destroy: destroy,
653
+ toast: toast,
654
+ dismiss: dismiss,
655
+ tabs: initTabs,
656
+ version: VERSION,
657
+ _autoStart: autoStart
658
+ };
659
+
660
+ return publicApi;
661
+ });