griffincss-ui 0.26.2 → 0.27.1

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.
Files changed (80) hide show
  1. package/dist/griffincss-ui-scoped.css +1 -1
  2. package/dist/griffincss-ui.css +1 -1
  3. package/dist/griffincss-ui.js +1 -1
  4. package/dist/griffincss-ui.js.map +1 -1
  5. package/dist/griffinjs-combobox.js +1 -1
  6. package/dist/griffinjs-combobox.js.map +1 -1
  7. package/dist/griffinjs-core.js +1 -1
  8. package/dist/griffinjs-core.js.map +1 -1
  9. package/dist/griffinjs-counter.js +1 -1
  10. package/dist/griffinjs-counter.js.map +1 -1
  11. package/dist/griffinjs-datetime.js +1 -1
  12. package/dist/griffinjs-datetime.js.map +1 -1
  13. package/dist/griffinjs-dialog.js +1 -1
  14. package/dist/griffinjs-dialog.js.map +1 -1
  15. package/dist/griffinjs-dropdown.js +1 -1
  16. package/dist/griffinjs-dropdown.js.map +1 -1
  17. package/dist/griffinjs-fields.js +1 -1
  18. package/dist/griffinjs-fields.js.map +1 -1
  19. package/dist/griffinjs-file.js +1 -1
  20. package/dist/griffinjs-file.js.map +1 -1
  21. package/dist/griffinjs-lightbox.js +1 -1
  22. package/dist/griffinjs-lightbox.js.map +1 -1
  23. package/dist/griffinjs-loader.js +1 -1
  24. package/dist/griffinjs-loader.js.map +1 -1
  25. package/dist/griffinjs-mask.js +1 -1
  26. package/dist/griffinjs-mask.js.map +1 -1
  27. package/dist/griffinjs-megamenu.js +1 -1
  28. package/dist/griffinjs-megamenu.js.map +1 -1
  29. package/dist/griffinjs-otp.js +1 -1
  30. package/dist/griffinjs-otp.js.map +1 -1
  31. package/dist/griffinjs-phone.js +1 -1
  32. package/dist/griffinjs-phone.js.map +1 -1
  33. package/dist/griffinjs-range.js +1 -1
  34. package/dist/griffinjs-range.js.map +1 -1
  35. package/dist/griffinjs-rating.js +1 -1
  36. package/dist/griffinjs-rating.js.map +1 -1
  37. package/dist/griffinjs-slider.js +1 -1
  38. package/dist/griffinjs-slider.js.map +1 -1
  39. package/dist/griffinjs-sortable.js +1 -1
  40. package/dist/griffinjs-sortable.js.map +1 -1
  41. package/dist/griffinjs-tooltip.js +1 -1
  42. package/dist/griffinjs-tooltip.js.map +1 -1
  43. package/dist/griffinjs-track.js +1 -1
  44. package/dist/griffinjs-track.js.map +1 -1
  45. package/dist/griffinjs-validate.js +1 -1
  46. package/dist/griffinjs-validate.js.map +1 -1
  47. package/dist/griffinjs.css +1 -1
  48. package/dist/griffinjs.js +1 -1
  49. package/dist/griffinjs.js.map +1 -1
  50. package/package.json +2 -2
  51. package/scss/_prose.scss +116 -0
  52. package/scss/_steps.scss +81 -38
  53. package/scss/_table.scss +17 -6
  54. package/scss/griffincss-ui-scoped.scss +1 -0
  55. package/scss/griffincss-ui.scss +1 -0
  56. package/scss/griffinjs/_track.scss +10 -0
  57. package/scss/griffinjs/griffinjs.scss +1 -1
  58. package/src/griffincss-ui.js +5 -5
  59. package/src/griffinjs/core/griffinjs-core.js +10 -10
  60. package/src/griffinjs/core/loader.js +2 -2
  61. package/src/griffinjs/core/options.js +1 -1
  62. package/src/griffinjs/core/track.js +2 -2
  63. package/src/griffinjs/fields/counter.js +1 -1
  64. package/src/griffinjs/fields/datetime.js +2 -2
  65. package/src/griffinjs/fields/file.js +1 -1
  66. package/src/griffinjs/fields/mask.js +1 -1
  67. package/src/griffinjs/fields/otp.js +1 -1
  68. package/src/griffinjs/fields/phone.js +3 -3
  69. package/src/griffinjs/fields/rating.js +1 -1
  70. package/src/griffinjs/fields/validate.js +1 -1
  71. package/src/griffinjs/widgets/combobox.js +19 -6
  72. package/src/griffinjs/widgets/dialog.js +41 -12
  73. package/src/griffinjs/widgets/dropdown.js +1 -1
  74. package/src/griffinjs/widgets/gallery.js +1 -1
  75. package/src/griffinjs/widgets/lightbox.js +67 -20
  76. package/src/griffinjs/widgets/megamenu.js +28 -8
  77. package/src/griffinjs/widgets/range.js +2 -2
  78. package/src/griffinjs/widgets/slider.js +1 -1
  79. package/src/griffinjs/widgets/sortable.js +1 -1
  80. package/src/griffinjs/widgets/tooltip.js +1 -1
@@ -19,9 +19,12 @@
19
19
  * * остановка медиа при закрытии: <video>/<audio> на паузу, <iframe>
20
20
  * разгружается и возвращается при следующем открытии;
21
21
  * * ajax-содержимое: data-gr-src грузится один раз в [data-gr-content]
22
- * (или в само окно), состояния loading → open | error;
22
+ * (или в само окно), состояния loading → open | error. Только ответ
23
+ * своего origin с типом text/html: чужой адрес, data: и прочие
24
+ * схемы — error без запроса;
23
25
  * * hash: открытие пишет #id в историю, «Назад» закрывает окно,
24
- * а страница, открытая с #id, показывает окно сразу;
26
+ * а страница, открытая с #id, показывает окно сразу — закрытие
27
+ * такого окна снимает #id из адреса на месте, а не шагом назад;
25
28
  * * рост окна (Этап 48d) — data-gr-dialog="grow": ResizeObserver
26
29
  * на окне, и смена высоты больше 24 px идёт анимацией от прежней
27
30
  * к новой (element.animate по block-size — не спорит с transition
@@ -80,6 +83,28 @@
80
83
  return (value || '').replace(/^#/, '');
81
84
  }
82
85
 
86
+ // Фрагмент — только ответ своего origin с типом text/html: разметку
87
+ // из innerHTML браузер оживляет, и <img onerror> чужого ответа исполнился
88
+ // бы в origin страницы. Адрес — http(s) своего origin (у data: origin —
89
+ // строка 'null'); редирект на чужой отклоняет mode: 'same-origin'; тип
90
+ // ответа отсекает свой JSON или текст, отражающий запрос.
91
+ function fragment(src) {
92
+ var url = null;
93
+
94
+ try { url = new URL(src, document.baseURI); } catch (e) { /* не адрес */ }
95
+
96
+ if (!url || !/^https?:$/.test(url.protocol) || url.origin !== location.origin) {
97
+ return Promise.reject(new Error('foreign origin'));
98
+ }
99
+
100
+ return fetch(url.href, { mode: 'same-origin' }).then(function (response) {
101
+ if (!response.ok) throw new Error('HTTP ' + response.status);
102
+ if (!/^text\/html/i.test(response.headers.get('content-type'))) throw new Error('not text/html');
103
+
104
+ return response.text();
105
+ });
106
+ }
107
+
83
108
  // Прокручиваемый предок внутри окна: жест в теле окна с прокруткой —
84
109
  // законный, всё остальное двигало бы страницу.
85
110
  function scrollable(node, root) {
@@ -135,6 +160,7 @@
135
160
  var loaded = false;
136
161
  var frames = []; // [{el, src}] — разгруженные iframe
137
162
  var byHistory = false; // закрыто кнопкой «Назад»: back() не зовём
163
+ var pushed = false; // #id в адресе записало само окно
138
164
  var record = { el: el, o: o };
139
165
  var grow = null; // ResizeObserver — только по grow
140
166
  var known = 0; // высота окна по последнему наблюдению
@@ -181,7 +207,7 @@
181
207
  loaded = true;
182
208
 
183
209
  if (typeof fetch !== 'function') {
184
- G.warn('окно: fetch недоступен, ' + src + ' не загружен');
210
+ G.warn('dialog: fetch is unavailable, ' + src + ' not loaded');
185
211
  setAttr(el, 'data-gr-state', 'error');
186
212
  return;
187
213
  }
@@ -190,15 +216,11 @@
190
216
 
191
217
  setAttr(el, 'data-gr-state', 'loading');
192
218
 
193
- fetch(src).then(function (response) {
194
- if (!response.ok) throw new Error('HTTP ' + response.status);
195
-
196
- return response.text();
197
- }).then(function (html) {
219
+ fragment(src).then(function (html) {
198
220
  target.innerHTML = html;
199
221
  setAttr(el, 'data-gr-state', isOpen() ? 'open' : 'ready');
200
222
  }, function (error) {
201
- G.warn('окно: ' + src + ' не загружен: ' + (error && error.message));
223
+ G.warn('dialog: ' + src + ' not loaded: ' + (error && error.message));
202
224
  setAttr(el, 'data-gr-state', 'error');
203
225
  });
204
226
  }
@@ -244,13 +266,19 @@
244
266
  function pushHash() {
245
267
  if (!o.hash || !id() || typeof history === 'undefined' || hashIs()) return;
246
268
 
247
- try { history.pushState(null, '', '#' + id()); } catch (e) { /* file:// и прочее */ }
269
+ try { history.pushState(null, '', '#' + id()); pushed = true; } catch (e) { /* file:// и прочее */ }
248
270
  }
249
271
 
272
+ // Свой #id закрытие убирает шагом назад по истории. #id, пришедший
273
+ // со ссылкой (страница открыта сразу с окном), так не убрать: «назад»
274
+ // увёл бы с сайта. Он снимается из адреса на месте, без новой записи.
250
275
  function popHash() {
251
276
  if (!o.hash || byHistory || typeof history === 'undefined' || !hashIs()) return;
252
277
 
253
- try { history.back(); } catch (e) { /* без истории */ }
278
+ try {
279
+ if (pushed) history.back();
280
+ else history.replaceState(history.state, '', location.pathname + location.search);
281
+ } catch (e) { /* без истории */ }
254
282
  }
255
283
 
256
284
  // --- Открытие и закрытие ------------------------------------------------
@@ -291,6 +319,7 @@
291
319
  relock();
292
320
  stopMedia();
293
321
  popHash();
322
+ pushed = false;
294
323
  byHistory = false;
295
324
 
296
325
  if (el.getAttribute('data-gr-state') !== 'loading') setAttr(el, 'data-gr-state', 'ready');
@@ -356,7 +385,7 @@
356
385
  function open(target) {
357
386
  var w = instanceOf(target);
358
387
 
359
- if (!w) { G.warn('окно не найдено: ' + target); return null; }
388
+ if (!w) { G.warn('dialog not found: ' + target); return null; }
360
389
 
361
390
  return w.open();
362
391
  }
@@ -104,7 +104,7 @@
104
104
  var trigger = el.firstElementChild;
105
105
  var panel = trigger && trigger.nextElementSibling;
106
106
 
107
- if (!trigger || !panel) throw new Error('у дропдауна нет кнопки и панели');
107
+ if (!trigger || !panel) throw new Error('the dropdown has no button and panel');
108
108
 
109
109
  var kind = el.tagName === 'DETAILS' ? 'details' : panel.hasAttribute('popover') ? 'popover' : 'css';
110
110
  var opened = false; // для popover и css: <details> знает сам
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * GriffinJS — галерея: слайдер + полоса превью.
3
3
  *
4
- * <div class="gr-gallery" data-gr-gallery>
4
+ * <div data-gr-gallery>
5
5
  * <div class="gr-track">…большие кадры…</div>
6
6
  * <div class="gr-track gr-gallery-thumbs">
7
7
  * <button type="button"><img …></button> …
@@ -2,23 +2,36 @@
2
2
  * GriffinJS — лайтбокс: <dialog> + дорожка.
3
3
  *
4
4
  * <a href="large.jpg" data-gr-lightbox="promo" data-gr-caption="Подпись"><img src="thumb.jpg" alt="…"></a>
5
+ * <a href="https://rutube.ru/video/…/" data-gr-src="https://rutube.ru/play/embed/…" data-gr-lightbox>…</a>
5
6
  *
6
7
  * Значение атрибута — группа: элементы с одним значением листаются в одном
7
8
  * окне; пустое значение — окно на один элемент, и дорожка вырождается
8
- * в один кадр без кнопок. Тип — по адресу (картинка, видео, iframe для
9
- * YouTube/Vimeo) или явно: data-gr-type="iframe".
9
+ * в один кадр без кнопок. Адрес кадра — data-gr-src, без него — href:
10
+ * у видео это разные адреса — во фрейм встаёт только плеер, страницу ролика
11
+ * хостинги во фрейме не показывают, а ссылка без скрипта ведёт именно на неё.
12
+ * Тип — по адресу кадра (картинка, видео, iframe для плееров YouTube, Vimeo,
13
+ * Rutube и VK Видео — по хосту адреса) или явно: data-gr-type="iframe".
10
14
  *
11
- * База без JS — ссылка на большое изображение. Окно строится при открытии
12
- * и уничтожается при закрытии: так видео гарантированно останавливается,
13
- * а в документе не копится по <dialog> на каждую группу. Верхний слой,
14
- * ловушка фокуса, Esc, возврат фокуса — от <dialog>.
15
+ * Адрес кадра — http(s), относительный, data: или blob:. Любой другой кадр
16
+ * не открывает: javascript: во фрейме исполнился бы в origin страницы,
17
+ * а data-* редакторы и фильтры CMS не проверяют как ссылку. Отвергнутый
18
+ * кадр из группы выпадает; щелчок по нему остаётся браузеру.
19
+ *
20
+ * База без JS — ссылка на большое изображение или на страницу ролика.
21
+ * Окно строится при открытии и уничтожается при закрытии: так видео
22
+ * гарантированно останавливается, а в документе не копится по <dialog>
23
+ * на каждую группу. Верхний слой, ловушка фокуса, Esc, возврат фокуса —
24
+ * от <dialog>.
15
25
  */
16
26
  (function (G) {
17
27
  'use strict';
18
28
 
19
29
  var ATTR = 'data-gr-lightbox';
20
30
  var VIDEO = /\.(mp4|webm|ogv|mov)(\?|#|$)/i;
21
- var EMBED = /youtube\.com|youtu\.be|vimeo\.com/i;
31
+ // Хост и путь разобранного адреса: хостинг — сам хост или его поддомен,
32
+ // а не подстрока где угодно (картинка …/rutube.ru-logo.png — не плеер).
33
+ var EMBED = /^([\w-]+\.)*((youtube\.com|youtu\.be|vimeo\.com|rutube\.ru|vkvideo\.ru)\/|vk\.com\/video)/i;
34
+ var SCHEMES = /^(https?|data|blob):$/;
22
35
 
23
36
  var dialog = null;
24
37
  var track = null;
@@ -26,19 +39,31 @@
26
39
  var events = G.listeners();
27
40
  var listen = events.add;
28
41
 
42
+ // Адрес по правилам браузера: регистр, пробелы по краям и табы внутри
43
+ // схемы разбор снимает. Не адрес — null.
44
+ function parse(src) {
45
+ try {
46
+ return new URL(src, document.baseURI);
47
+ } catch (e) {
48
+ return null;
49
+ }
50
+ }
51
+
29
52
  function typeOf(node, src) {
30
53
  var explicit = node && node.getAttribute && node.getAttribute('data-gr-type');
31
54
 
32
55
  if (explicit) return explicit;
33
56
  if (VIDEO.test(src)) return 'video';
34
- if (EMBED.test(src)) return 'iframe';
35
57
 
36
- return 'image';
58
+ var url = parse(src);
59
+
60
+ return url && EMBED.test(url.hostname + url.pathname) ? 'iframe' : 'image';
37
61
  }
38
62
 
39
- // Элемент разметки → описание кадра.
63
+ // Элемент разметки → описание кадра. data-gr-src старше href: у видео
64
+ // href — страница ролика для перехода без скрипта, а кадр — плеер.
40
65
  function itemOf(node) {
41
- var src = node.getAttribute('href') || node.getAttribute('data-gr-src') || '';
66
+ var src = node.getAttribute('data-gr-src') || node.getAttribute('href') || '';
42
67
  var img = node.querySelector ? node.querySelector('img') : null;
43
68
 
44
69
  return {
@@ -91,21 +116,41 @@
91
116
  // --- Открытие ----------------------------------------------------------------
92
117
 
93
118
  function open(items, index, opts) {
119
+ var list = [];
120
+
121
+ index = index || 0;
122
+
123
+ // Кадр с отвергнутым адресом выпадает из группы; отвергнут
124
+ // открываемый — окна нет.
125
+ for (var k = 0; k < items.length; k++) {
126
+ var url = parse(items[k].src);
127
+
128
+ if (url && SCHEMES.test(url.protocol)) {
129
+ list.push(items[k]);
130
+ continue;
131
+ }
132
+
133
+ G.warn('lightbox: frame address refused: ' + items[k].src);
134
+
135
+ if (k === index) return null;
136
+ if (k < index) index--;
137
+ }
138
+
139
+ if (!list.length) return null;
140
+
94
141
  close();
95
142
 
96
143
  opts = opts || {};
97
- index = index || 0;
144
+ items = list;
98
145
 
99
146
  var n = items.length;
100
147
 
101
- if (n === 0) return null;
102
-
103
148
  dialog = element('dialog', 'gr-modal gr-lightbox', { 'aria-label': opts.label || 'Просмотр' });
104
149
 
105
150
  var trackEl = element('div', 'gr-track gr-lightbox-track', { tabindex: '0' });
106
151
 
107
152
  for (var i = 0; i < n; i++) {
108
- var figure = element('figure', 'gr-slide gr-lightbox-item');
153
+ var figure = element('figure', 'gr-lightbox-item');
109
154
 
110
155
  figure.appendChild(media(items[i]));
111
156
 
@@ -177,8 +222,6 @@
177
222
  }
178
223
 
179
224
  function openFrom(node) {
180
- trigger = node;
181
-
182
225
  var group = groupOf(node);
183
226
  var items = [];
184
227
  var index = 0;
@@ -188,7 +231,11 @@
188
231
  items.push(itemOf(group[i]));
189
232
  }
190
233
 
191
- return open(items, index);
234
+ var opened = open(items, index);
235
+
236
+ if (opened) trigger = node;
237
+
238
+ return opened;
192
239
  }
193
240
 
194
241
  // --- Закрытие ----------------------------------------------------------------
@@ -235,10 +282,10 @@
235
282
 
236
283
  function onClick(event, node) {
237
284
  // Ctrl/Cmd/средняя кнопка — читатель хочет новую вкладку, а не окно.
238
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.button) return false;
285
+ // Кадр не открылся — ссылка работает как без скрипта.
286
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.button || !openFrom(node)) return false;
239
287
 
240
288
  if (event.preventDefault) event.preventDefault();
241
- openFrom(node);
242
289
 
243
290
  return true;
244
291
  }
@@ -27,7 +27,9 @@
27
27
  * закрывает и возвращает фокус на заголовок;
28
28
  * * закрытие щелчком мимо полосы и уходом фокуса;
29
29
  * * ajax-панели: data-gr-src на пункте грузится при первом открытии,
30
- * состояния loading → ready | error пишутся на пункт;
30
+ * состояния loading → ready | error пишутся на пункт. Только ответ
31
+ * своего origin с типом text/html: чужой адрес, data: и прочие
32
+ * схемы — error без запроса;
31
33
  * * закрытие с состоянием (Этап 48d): панель, чей уход тема анимирует,
32
34
  * не пропадает в первом же кадре — на пункт ставится
33
35
  * data-gr-state="closing", open снимается по transitionend/animationend
@@ -98,6 +100,28 @@
98
100
  return document.activeElement === node;
99
101
  }
100
102
 
103
+ // Фрагмент — только ответ своего origin с типом text/html: разметку
104
+ // из innerHTML браузер оживляет, и <img onerror> чужого ответа исполнился
105
+ // бы в origin страницы. Адрес — http(s) своего origin (у data: origin —
106
+ // строка 'null'); редирект на чужой отклоняет mode: 'same-origin'; тип
107
+ // ответа отсекает свой JSON или текст, отражающий запрос.
108
+ function fragment(src) {
109
+ var url = null;
110
+
111
+ try { url = new URL(src, document.baseURI); } catch (e) { /* не адрес */ }
112
+
113
+ if (!url || !/^https?:$/.test(url.protocol) || url.origin !== location.origin) {
114
+ return Promise.reject(new Error('foreign origin'));
115
+ }
116
+
117
+ return fetch(url.href, { mode: 'same-origin' }).then(function (response) {
118
+ if (!response.ok) throw new Error('HTTP ' + response.status);
119
+ if (!/^text\/html/i.test(response.headers.get('content-type'))) throw new Error('not text/html');
120
+
121
+ return response.text();
122
+ });
123
+ }
124
+
101
125
  // Длительность --gr-transition у узла, мс: «0.2s ease» → 200, «150ms» → 150,
102
126
  // пусто или 0s → 0. Одно чтение вычисленного стиля при закрытии.
103
127
  function duration(node) {
@@ -282,22 +306,18 @@
282
306
  item.loaded = true;
283
307
 
284
308
  if (typeof fetch !== 'function') {
285
- G.warn('мегаменю: fetch недоступен, панель ' + src + ' не загружена');
309
+ G.warn('megamenu: fetch is unavailable, panel ' + src + ' not loaded');
286
310
  setAttr(item.el, 'data-gr-state', 'error');
287
311
  return;
288
312
  }
289
313
 
290
314
  setAttr(item.el, 'data-gr-state', 'loading');
291
315
 
292
- fetch(src).then(function (response) {
293
- if (!response.ok) throw new Error('HTTP ' + response.status);
294
-
295
- return response.text();
296
- }).then(function (html) {
316
+ fragment(src).then(function (html) {
297
317
  item.panel.innerHTML = html;
298
318
  setAttr(item.el, 'data-gr-state', 'ready');
299
319
  }, function (error) {
300
- G.warn('мегаменю: панель ' + src + ' не загружена: ' + (error && error.message));
320
+ G.warn('megamenu: panel ' + src + ' not loaded: ' + (error && error.message));
301
321
  setAttr(item.el, 'data-gr-state', 'error');
302
322
  });
303
323
  }
@@ -74,7 +74,7 @@
74
74
  var listeners = G.listeners();
75
75
  var fields = [];
76
76
 
77
- if (!inputs.length) throw new Error('внутри нет ни одного <input type="range">');
77
+ if (!inputs.length) throw new Error('no <input type="range"> inside');
78
78
 
79
79
  function doneOf(input) {
80
80
  var min = edge(input, 'min', 0);
@@ -172,7 +172,7 @@
172
172
  for (var f = 0; f < inputs.length; f++) {
173
173
  var field = document.querySelector(selectors[f] || '#');
174
174
 
175
- if (!field) throw new Error('поле «' + selectors[f] + '» не найдено');
175
+ if (!field) throw new Error('field "' + selectors[f] + '" not found');
176
176
 
177
177
  fields.push(field);
178
178
  pull(f);
@@ -43,7 +43,7 @@
43
43
 
44
44
  var trackEl = el.querySelector('.gr-track') || el.firstElementChild;
45
45
 
46
- if (!trackEl) throw new Error('у слайдера нет дорожки');
46
+ if (!trackEl) throw new Error('the slider has no track');
47
47
 
48
48
  var track = G.track(trackEl, { loop: o.loop, rewind: o.rewind, step: o.step, engine: o.engine, index: o.index || 0 });
49
49
  var attrs = G.recorder();
@@ -89,7 +89,7 @@
89
89
  return el.querySelector('tbody');
90
90
  }
91
91
 
92
- if (!bodyOf() || !heads.length) throw new Error('нужны tbody и заголовки с aria-sort');
92
+ if (!bodyOf() || !heads.length) throw new Error('needs tbody and headers with aria-sort');
93
93
 
94
94
  var attrs = G.recorder();
95
95
  var events = G.listeners();
@@ -53,7 +53,7 @@
53
53
  var target = el.firstElementChild;
54
54
  var tip = el.querySelector('[role="tooltip"]') || el.lastElementChild;
55
55
 
56
- if (!target || !tip || tip === target) throw new Error('у подсказки нет якоря и текста');
56
+ if (!target || !tip || tip === target) throw new Error('the tooltip has no anchor and text');
57
57
 
58
58
  var shown = false;
59
59
  var timer = null;