griffincss-ui 0.27.0 → 0.28.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.
Files changed (72) hide show
  1. package/dist/griffincss-ui.js +1 -1
  2. package/dist/griffincss-ui.js.map +1 -1
  3. package/dist/griffinjs-combobox.js +1 -1
  4. package/dist/griffinjs-combobox.js.map +1 -1
  5. package/dist/griffinjs-core.js +1 -1
  6. package/dist/griffinjs-core.js.map +1 -1
  7. package/dist/griffinjs-counter.js +1 -1
  8. package/dist/griffinjs-counter.js.map +1 -1
  9. package/dist/griffinjs-datetime.js +1 -1
  10. package/dist/griffinjs-datetime.js.map +1 -1
  11. package/dist/griffinjs-dialog.js +1 -1
  12. package/dist/griffinjs-dialog.js.map +1 -1
  13. package/dist/griffinjs-dropdown.js +1 -1
  14. package/dist/griffinjs-dropdown.js.map +1 -1
  15. package/dist/griffinjs-fields.js +1 -1
  16. package/dist/griffinjs-fields.js.map +1 -1
  17. package/dist/griffinjs-file.js +1 -1
  18. package/dist/griffinjs-file.js.map +1 -1
  19. package/dist/griffinjs-lightbox.js +1 -1
  20. package/dist/griffinjs-lightbox.js.map +1 -1
  21. package/dist/griffinjs-loader.js +1 -1
  22. package/dist/griffinjs-loader.js.map +1 -1
  23. package/dist/griffinjs-mask.js +1 -1
  24. package/dist/griffinjs-mask.js.map +1 -1
  25. package/dist/griffinjs-megamenu.js +1 -1
  26. package/dist/griffinjs-megamenu.js.map +1 -1
  27. package/dist/griffinjs-otp.js +1 -1
  28. package/dist/griffinjs-otp.js.map +1 -1
  29. package/dist/griffinjs-phone.js +1 -1
  30. package/dist/griffinjs-phone.js.map +1 -1
  31. package/dist/griffinjs-range.js +1 -1
  32. package/dist/griffinjs-range.js.map +1 -1
  33. package/dist/griffinjs-rating.js +1 -1
  34. package/dist/griffinjs-rating.js.map +1 -1
  35. package/dist/griffinjs-scroll.js +1 -1
  36. package/dist/griffinjs-scroll.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.js +1 -1
  48. package/dist/griffinjs.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/griffincss-ui.js +17 -9
  51. package/src/griffinjs/core/griffinjs-core.js +18 -11
  52. package/src/griffinjs/core/loader.js +6 -3
  53. package/src/griffinjs/core/options.js +1 -1
  54. package/src/griffinjs/core/track.js +1 -1
  55. package/src/griffinjs/engines/scroll.js +14 -0
  56. package/src/griffinjs/fields/counter.js +4 -2
  57. package/src/griffinjs/fields/datetime.js +5 -4
  58. package/src/griffinjs/fields/file.js +6 -2
  59. package/src/griffinjs/fields/mask.js +1 -1
  60. package/src/griffinjs/fields/otp.js +1 -1
  61. package/src/griffinjs/fields/phone.js +3 -3
  62. package/src/griffinjs/fields/rating.js +1 -1
  63. package/src/griffinjs/fields/validate.js +7 -4
  64. package/src/griffinjs/widgets/combobox.js +52 -7
  65. package/src/griffinjs/widgets/dialog.js +58 -13
  66. package/src/griffinjs/widgets/dropdown.js +1 -1
  67. package/src/griffinjs/widgets/lightbox.js +108 -19
  68. package/src/griffinjs/widgets/megamenu.js +45 -9
  69. package/src/griffinjs/widgets/range.js +6 -3
  70. package/src/griffinjs/widgets/slider.js +1 -1
  71. package/src/griffinjs/widgets/sortable.js +1 -1
  72. package/src/griffinjs/widgets/tooltip.js +1 -1
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * GriffinJS — Core v0.27.0
2
+ * GriffinJS — Core v0.28.0
3
3
  * Опциональный слой виджетов с состоянием поверх Griffincss: слайдер,
4
4
  * галерея, лайтбокс, параллакс, мегаменю. Подключается одной строкой
5
5
  * и одним файлом; страница без него — законное и рабочее состояние.
@@ -45,7 +45,7 @@
45
45
  })(function () {
46
46
  'use strict';
47
47
 
48
- var VERSION = '0.27.0';
48
+ var VERSION = '0.28.0';
49
49
 
50
50
  // Реестры. Модуль зовёт defineEngine/defineWidget; ядро — единственное
51
51
  // место, которое знает, как их применить.
@@ -84,13 +84,13 @@
84
84
  if (typeof opts === 'function') { factory = opts; opts = null; }
85
85
 
86
86
  if (typeof name !== 'string' || !name) {
87
- throw new Error('GriffinJS: ' + kind + ' без имени');
87
+ throw new Error('GriffinJS: ' + kind + ' without a name');
88
88
  }
89
89
  if (typeof factory !== 'function') {
90
- throw new Error('GriffinJS: ' + kind + ' «' + name + '» — не функция');
90
+ throw new Error('GriffinJS: ' + kind + ' "' + name + '" is not a function');
91
91
  }
92
92
  if (registry[name]) {
93
- throw new Error('GriffinJS: ' + kind + ' «' + name + '» уже зарегистрирован');
93
+ throw new Error('GriffinJS: ' + kind + ' "' + name + '" is already registered');
94
94
  }
95
95
 
96
96
  if (opts && opts.needs) needs(name, opts.needs);
@@ -101,11 +101,11 @@
101
101
  }
102
102
 
103
103
  function defineEngine(name, opts, factory) {
104
- return define(engines, 'движок', name, opts, factory);
104
+ return define(engines, 'engine', name, opts, factory);
105
105
  }
106
106
 
107
107
  function defineWidget(name, opts, factory) {
108
- return define(widgets, 'виджет', name, opts, factory);
108
+ return define(widgets, 'widget', name, opts, factory);
109
109
  }
110
110
 
111
111
  // Проверка сборки на старте: модуль подключён, а часть, которую он берёт
@@ -124,7 +124,7 @@
124
124
  // Зависимостью бывает и часть G (track, anchor), и целый модуль:
125
125
  // галерея строится поверх слайдера.
126
126
  if (!api[dep] && !widgets[dep] && !engines[dep]) {
127
- warn('«' + names[i] + '» не работает без griffinjs-' + dep + '.js');
127
+ warn('"' + names[i] + '" does not work without griffinjs-' + dep + '.js');
128
128
  }
129
129
  }
130
130
  }
@@ -228,7 +228,7 @@
228
228
  // два слайдера на одной дорожке — ошибка автора, а не два состояния.
229
229
  function mount(el, name, opts) {
230
230
  if (!el || !widgets[name]) {
231
- warn('виджет «' + name + '» не зарегистрирован');
231
+ warn('widget "' + name + '" is not registered');
232
232
  return null;
233
233
  }
234
234
 
@@ -244,7 +244,7 @@
244
244
  record.instance = widgets[name](el, opts, api) || {};
245
245
  } catch (e) {
246
246
  mounted.splice(mounted.indexOf(record), 1);
247
- warn('виджет «' + name + '» не поднялся: ' + (e && e.message));
247
+ warn('widget "' + name + '" failed to mount: ' + (e && e.message));
248
248
  return null;
249
249
  }
250
250
 
@@ -369,7 +369,14 @@
369
369
  function autoStart() {
370
370
  var script = null;
371
371
 
372
- try { script = document.currentScript; } catch (e) { /* currentScript недоступен */ }
372
+ // Флаги — только у настоящего тега: именованный элемент перекрывает
373
+ // свойство документа — <img name="currentScript"> даёт картинку,
374
+ // <iframe name> — окно фрейма (у чужого origin чтение бросает).
375
+ try {
376
+ var tag = document.currentScript;
377
+
378
+ if (tag && tag.tagName === 'SCRIPT') script = tag;
379
+ } catch (e) { /* currentScript недоступен */ }
373
380
 
374
381
  if (script && script.getAttribute('data-auto') === 'false') return;
375
382
 
@@ -37,10 +37,13 @@
37
37
  var loading = null; // null — не запрашивали; pending | done | failed
38
38
  var warned = {}; // поле → без config уже сказано
39
39
 
40
+ // Только настоящий тег: именованный элемент перекрывает свойство
41
+ // документа, и <img name="currentScript" data-fields> дал бы адрес
42
+ // бандла из чужой разметки.
40
43
  try {
41
44
  var script = document.currentScript;
42
45
 
43
- if (script) {
46
+ if (script && script.tagName === 'SCRIPT') {
44
47
  nonce = script.nonce || '';
45
48
 
46
49
  var src = script.getAttribute('data-fields');
@@ -62,7 +65,7 @@
62
65
  if (config.fields && config.fields.src) return request();
63
66
 
64
67
  warned[name] = true;
65
- G.warn('поле «' + name + '» не собрано: подключите griffinjs-fields.js или задайте GriffinJS.config.fields');
68
+ G.warn('field "' + name + '" is not loaded: include griffinjs-fields.js or set GriffinJS.config.fields');
66
69
  }
67
70
  }
68
71
 
@@ -85,7 +88,7 @@
85
88
  function settle(event) {
86
89
  if (event.type === 'error') {
87
90
  ok = false;
88
- G.warn('бандл полей не загружен: ' + event.target.src + ' — поля остались базой без скрипта');
91
+ G.warn('fields bundle failed to load: ' + event.target.src + '; fields stay without the script');
89
92
  }
90
93
 
91
94
  if (--left) return;
@@ -37,7 +37,7 @@
37
37
  try {
38
38
  return JSON.parse(text);
39
39
  } catch (e) {
40
- G.warn('параметры не разобраны как JSON: ' + text);
40
+ G.warn('options are not valid JSON: ' + text);
41
41
  return out;
42
42
  }
43
43
  }
@@ -542,7 +542,7 @@
542
542
  var factory = G.engines[o.engine];
543
543
 
544
544
  if (!factory) {
545
- G.warn('движок «' + o.engine + '» не зарегистрирован — дорожка без движения');
545
+ G.warn('engine "' + o.engine + '" is not registered; the track will not move');
546
546
  } else {
547
547
  engine = factory(api, el, o) || null;
548
548
  }
@@ -54,8 +54,22 @@
54
54
 
55
55
  // --- Клоны -----------------------------------------------------------------
56
56
 
57
+ // Клон — декорация на время доезда до края: медиа в нём не грузится.
58
+ // Плеер во фрейме-клоне грузился бы вторым (с autoplay — играл бы
59
+ // невидимо), <video> качал бы файл ещё раз; src снимается до вставки.
57
60
  function makeClone(slide) {
58
61
  var node = slide.cloneNode(true);
62
+ var media = node.querySelectorAll('iframe,video,audio,source');
63
+
64
+ // Шаг −1 — сам слайд: видео-слайдер ставит слайдом <video>;
65
+ // картинка-слайд остаётся с src — она и видна на доезде.
66
+ for (var i = -1; i < media.length; i++) {
67
+ var m = i < 0 ? node : media[i];
68
+
69
+ if (m.tagName === 'IMG') continue;
70
+ m.removeAttribute('src');
71
+ m.removeAttribute('srcdoc');
72
+ }
59
73
 
60
74
  node.setAttribute(CLONE, '');
61
75
  node.setAttribute('aria-hidden', 'true');
@@ -32,9 +32,11 @@
32
32
  var attrs = G.recorder();
33
33
  var events = G.listeners();
34
34
 
35
- if (!(max > 0)) throw new Error('у поля нет maxlength — считать не к чему');
35
+ if (!(max > 0)) throw new Error('the field has no maxlength: nothing to count');
36
36
 
37
- var out = o.out ? document.querySelector(o.out) : null;
37
+ // Цель — в своей форме (без формы — в документе): out приходит
38
+ // и из данных, и out: main не должен сделать подписью <main>.
39
+ var out = o.out ? (G.closest(el, 'form') || document).querySelector(o.out) : null;
38
40
  var created = false;
39
41
 
40
42
  if (!out) {
@@ -235,7 +235,7 @@
235
235
  var native = el.getAttribute('type');
236
236
  var mode = MODES[native];
237
237
 
238
- if (!mode) throw new Error('нужен type="date", "time" или "datetime-local"');
238
+ if (!mode) throw new Error('needs type="date", "time" or "datetime-local"');
239
239
 
240
240
  var attrs = G.recorder();
241
241
  var events = G.listeners(); // на всё время жизни
@@ -792,12 +792,13 @@
792
792
 
793
793
  // Сосед по паре: уже поднятый — как есть, иначе поднимается здесь;
794
794
  // сканер на нём потом найдёт живой экземпляр.
795
+ // Сосед — поле с data-gr-datetime своей формы (без формы — документа).
795
796
  if (o.to && mode !== 'time') {
796
- var target = document.querySelector(o.to);
797
- var other = target && G.mount(target, 'datetime');
797
+ var target = (G.closest(el, 'form') || document).querySelector(o.to);
798
+ var other = target && target.hasAttribute('data-gr-datetime') && G.mount(target, 'datetime');
798
799
 
799
800
  if (other) { peer = other; role = 'from'; other.pair(api); push(); }
800
- else G.warn('datetime: поле «по» не найдено — ' + o.to);
801
+ else G.warn('datetime: the "to" field is not found: ' + o.to);
801
802
  }
802
803
 
803
804
  return api;
@@ -77,12 +77,16 @@
77
77
  G.defineWidget('file', function (el, opts) {
78
78
  var o = G.merge(DEFAULTS, opts);
79
79
 
80
- if (typeof DataTransfer !== 'function') throw new Error('без DataTransfer убрать один файл из выбранного нельзя');
80
+ // Только на самом поле: на обёртке change любого потомка чистил бы цель.
81
+ if (String(el.tagName).toLowerCase() !== 'input' || String(el.getAttribute('type')).toLowerCase() !== 'file') throw new Error('needs <input type="file">');
82
+
83
+ if (typeof DataTransfer !== 'function') throw new Error('without DataTransfer a single file cannot be removed from the selection');
81
84
 
82
85
  var attrs = G.recorder();
83
86
  var events = G.listeners();
84
87
  var locale = lang();
85
- var list = o.list ? document.querySelector(o.list) : null;
88
+ // Цель — в своей форме (без формы — в документе).
89
+ var list = o.list ? (G.closest(el, 'form') || document).querySelector(o.list) : null;
86
90
  var created = false;
87
91
  var known = [];
88
92
 
@@ -163,7 +163,7 @@
163
163
  // разобрался бы как пара «00: 00», поэтому без ключа берётся строка как есть.
164
164
  var format = o.format ? String(o.format) : (el.getAttribute('data-gr-mask') || '').trim();
165
165
 
166
- if (!format) throw new Error('у маски нет формата');
166
+ if (!format) throw new Error('the mask has no format');
167
167
 
168
168
  var tokens;
169
169
  var last = null; // последнее записанное значение
@@ -35,7 +35,7 @@
35
35
  G.defineWidget('otp', function (el) {
36
36
  var cells = el.querySelectorAll('input');
37
37
 
38
- if (!cells.length) throw new Error('внутри нет ни одной ячейки <input>');
38
+ if (!cells.length) throw new Error('no <input> cells inside');
39
39
 
40
40
  var attrs = G.recorder();
41
41
  var events = G.listeners();
@@ -93,7 +93,7 @@
93
93
  var input = el.querySelector('input');
94
94
  var locale = lang();
95
95
 
96
- if (!input) throw new Error('у поля телефона нет <input>');
96
+ if (!input) throw new Error('the phone field has no <input>');
97
97
 
98
98
  var created = []; // позиции, добавленные из таблицы
99
99
  var texts = []; // [позиция, подпись автора] — для destroy
@@ -142,11 +142,11 @@
142
142
  // Без country первой стоит первая по алфавиту языка страницы —
143
143
  // на русской это Австралия, +61. Угадывать страну за автора виджет
144
144
  // не берётся: ошибся бы тише, чем сейчас. Но и молчать не должен.
145
- if (!o.country) G.warn('телефон: страна не задана — выбрана первая по алфавиту; укажите country');
145
+ if (!o.country) G.warn('phone: no country set, the first one alphabetically is used; set country');
146
146
  }
147
147
 
148
148
  if (!options().length) {
149
- G.warn('телефон: список кодов пуст — перечислите <option> сами или подключите griffinjs-countries.js');
149
+ G.warn('phone: the code list is empty; list the <option>s yourself or include griffinjs-countries.js');
150
150
  }
151
151
 
152
152
  // Подписи: флаг + имя в языке страницы + код. Только там, где есть ISO
@@ -37,7 +37,7 @@
37
37
  var display = el.querySelector('.gr-rating');
38
38
  var radios = el.querySelectorAll('input[type="radio"]');
39
39
 
40
- if (!display || !radios.length) throw new Error('нужны .gr-rating и <input type="radio"> внутри');
40
+ if (!display || !radios.length) throw new Error('needs .gr-rating and <input type="radio"> inside');
41
41
 
42
42
  var attrs = G.recorder();
43
43
  var events = G.listeners();
@@ -45,12 +45,13 @@
45
45
  }
46
46
 
47
47
  G.defineWidget('validate', function (el, opts) {
48
- if (String(el.tagName).toLowerCase() !== 'form') throw new Error('нужен <form>');
48
+ if (String(el.tagName).toLowerCase() !== 'form') throw new Error('needs a <form>');
49
49
 
50
50
  var o = G.merge(DEFAULTS, opts);
51
51
  var attrs = G.recorder();
52
52
  var events = G.listeners();
53
- var box = o.summary ? document.querySelector(o.summary) : null;
53
+ // Сводка — внутри своей формы: summary: main спрятал бы <main>.
54
+ var box = o.summary ? el.querySelector(o.summary) : null;
54
55
  var created = false;
55
56
  var marked = []; // поля, которым поставлен aria-invalid
56
57
 
@@ -72,9 +73,11 @@
72
73
  return control.validity ? control.validity.valid === false : false;
73
74
  }
74
75
 
75
- // Подпись поля: <label>, иначе aria-label, иначе name.
76
+ // Подпись поля: <label>, иначе aria-label, иначе name. Только
77
+ // control.labels: селектор label[for="…"] из id с кавычкой бросал
78
+ // до preventDefault, и форма уходила без проверки.
76
79
  function labelOf(control) {
77
- var label = (control.labels && control.labels[0]) || (control.getAttribute('id') ? el.querySelector('label[for="' + control.getAttribute('id') + '"]') : null);
80
+ var label = control.labels && control.labels[0];
78
81
  var text = label ? String(label.textContent || '').trim() : '';
79
82
 
80
83
  return text || control.getAttribute('aria-label') || control.getAttribute('name') || '';
@@ -8,8 +8,16 @@
8
8
  * Источник — адрес с {q} (ответ — JSON: массив строк или объектов
9
9
  * {value, label, href, group, id}, либо {items: […]}) или функция
10
10
  * через JS: GriffinJS.mount(el, 'combobox', { source: (q, signal) => Promise }).
11
+ * С флагом navigate выбор позиции переходит по её href — только http(s).
11
12
  * Рендер позиции подменяется render(item) → узел.
12
13
  *
14
+ * Набранное уходит источнику с каждым запросом, и CORS этого не держит —
15
+ * он закрывает только чтение ответа. Поэтому адрес из разметки — только
16
+ * http(s) своего origin; другой сайт разрешает JS темы:
17
+ * GriffinJS.config.sources.push('https://api.example.ru') (origin, по
18
+ * умолчанию список пуст) — или функция source. Редирект не выполняется:
19
+ * по 302 запрос с набранным ушёл бы дальше.
20
+ *
13
21
  * База без JS — само поле: форма поиска отправляется Enter, как всегда.
14
22
  * Скрипт добавляет список под полем и то, что описано в WAI-ARIA combobox:
15
23
  * role=combobox с aria-autocomplete=list, listbox с option,
@@ -66,6 +74,26 @@
66
74
  };
67
75
 
68
76
  var seq = 0;
77
+ var config = G.config;
78
+
79
+ if (!config.sources) config.sources = [];
80
+
81
+ // Адрес запроса — только http(s) своего origin или origin из списка
82
+ // (записи сводятся к origin: путь и слеш в конце не мешают). Список
83
+ // читается при каждом запросе — присваивание работает, как push.
84
+ function allowed(src) {
85
+ var url = null;
86
+
87
+ try { url = new URL(src, document.baseURI); } catch (e) { /* не адрес */ }
88
+
89
+ if (url && /^https?:$/.test(url.protocol) && (url.origin === location.origin || [].concat(config.sources).some(function (entry) {
90
+ try { return new URL(entry).origin === url.origin; } catch (e) { return false; }
91
+ }))) return url;
92
+
93
+ G.warn('combobox: source refused: ' + src + ' (other origins: GriffinJS.config.sources)');
94
+
95
+ return null;
96
+ }
69
97
 
70
98
  function normalize(raw) {
71
99
  var items = raw && raw.items ? raw.items : raw;
@@ -140,12 +168,12 @@
140
168
 
141
169
  var input = el.querySelector('input');
142
170
 
143
- if (!input) throw new Error('у комбобокса нет поля ввода');
171
+ if (!input) throw new Error('the combobox has no input');
144
172
 
145
173
  var choice = o.multiple ? el.querySelector('select[multiple]') : null;
146
174
 
147
- if (o.multiple && !choice) throw new Error('для multiple нужен <select multiple> внутри');
148
- if (o.free && !choice) { G.warn('комбобокс: free без multiple смысла не имеет — флаг пропущен'); o.free = false; }
175
+ if (o.multiple && !choice) throw new Error('multiple needs a <select multiple> inside');
176
+ if (o.free && !choice) { G.warn('combobox: free makes no sense without multiple; flag ignored'); o.free = false; }
149
177
 
150
178
  var list = el.querySelector('[role="listbox"]');
151
179
  var created = false;
@@ -271,12 +299,16 @@
271
299
  if (typeof o.source === 'function') {
272
300
  promise = Promise.resolve(o.source(query, signal));
273
301
  } else if (o.src && typeof fetch === 'function') {
274
- promise = fetch(o.src.replace('{q}', encodeURIComponent(query)), { signal: signal }).then(function (response) {
302
+ var url = allowed(o.src.replace('{q}', encodeURIComponent(query)));
303
+
304
+ if (!url) return;
305
+
306
+ promise = fetch(url.href, { signal: signal, redirect: 'error' }).then(function (response) {
275
307
  if (!response.ok) throw new Error('HTTP ' + response.status);
276
308
  return response.json();
277
309
  });
278
310
  } else {
279
- G.warn('комбобокс: нет источника (src или source)');
311
+ G.warn('combobox: no source (src or source)');
280
312
  return;
281
313
  }
282
314
 
@@ -292,7 +324,7 @@
292
324
  render(query, found);
293
325
  }, function (error) {
294
326
  if (error && error.name === 'AbortError') return;
295
- G.warn('комбобокс: запрос не удался: ' + (error && error.message));
327
+ G.warn('combobox: request failed: ' + (error && error.message));
296
328
  });
297
329
  }
298
330
 
@@ -440,11 +472,24 @@
440
472
  close();
441
473
  G.emit('griffin:select', input, { item: item, index: index, input: input });
442
474
 
443
- if (o.navigate && item.href && typeof location !== 'undefined') location.href = item.href;
475
+ if (o.navigate && item.href && typeof location !== 'undefined') navigate(String(item.href));
444
476
 
445
477
  return api;
446
478
  }
447
479
 
480
+ // Переход — только по http(s): href приходит из ответа источника,
481
+ // и javascript: исполнился бы в origin страницы. Выбор при отказе
482
+ // остаётся — значение в поле, событие ушло. Адрес — строка, приведённая
483
+ // один раз: она проверяется, она же уходит в location.
484
+ function navigate(href) {
485
+ var url = null;
486
+
487
+ try { url = new URL(href, document.baseURI); } catch (e) { /* не адрес */ }
488
+
489
+ if (url && /^https?:$/.test(url.protocol)) location.href = href;
490
+ else G.warn('combobox: navigation refused: ' + href);
491
+ }
492
+
448
493
  // --- События --------------------------------------------------------------
449
494
 
450
495
  function onInput() { query(); }
@@ -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,44 @@
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
+ // text/htmlx — не HTML, а склейку двух заголовков («text/html,
92
+ // application/json») браузер читает по последнему типу.
93
+ function fragment(src) {
94
+ var url = null;
95
+
96
+ try { url = new URL(src, document.baseURI); } catch (e) { /* не адрес */ }
97
+
98
+ if (!url || !/^https?:$/.test(url.protocol) || url.origin !== location.origin) {
99
+ return Promise.reject(new Error('foreign origin'));
100
+ }
101
+
102
+ return fetch(url.href, { mode: 'same-origin' }).then(function (response) {
103
+ if (!response.ok) throw new Error('HTTP ' + response.status);
104
+ if (!/^text\/html\s*(;[^,]*)?$/i.test(response.headers.get('content-type'))) throw new Error('not text/html');
105
+
106
+ return response.text();
107
+ }).then(function (html) {
108
+ // Кусок разметки — без meta, base и link: refresh увёл бы страницу
109
+ // без щелчка, base сменил бы базу адресов всей страницы, а в куске
110
+ // разметки они — ошибка или подлог. В <template> они не срабатывают
111
+ // и снимаются до вставки; <script> куска не исполняется и так.
112
+ var tpl = document.createElement('template');
113
+
114
+ tpl.innerHTML = html;
115
+
116
+ var bad = tpl.content.querySelectorAll('meta, base, link');
117
+
118
+ for (var i = 0; i < bad.length; i++) bad[i].remove();
119
+
120
+ return tpl.content;
121
+ });
122
+ }
123
+
83
124
  // Прокручиваемый предок внутри окна: жест в теле окна с прокруткой —
84
125
  // законный, всё остальное двигало бы страницу.
85
126
  function scrollable(node, root) {
@@ -135,6 +176,7 @@
135
176
  var loaded = false;
136
177
  var frames = []; // [{el, src}] — разгруженные iframe
137
178
  var byHistory = false; // закрыто кнопкой «Назад»: back() не зовём
179
+ var pushed = false; // #id в адресе записало само окно
138
180
  var record = { el: el, o: o };
139
181
  var grow = null; // ResizeObserver — только по grow
140
182
  var known = 0; // высота окна по последнему наблюдению
@@ -181,7 +223,7 @@
181
223
  loaded = true;
182
224
 
183
225
  if (typeof fetch !== 'function') {
184
- G.warn('окно: fetch недоступен, ' + src + ' не загружен');
226
+ G.warn('dialog: fetch is unavailable, ' + src + ' not loaded');
185
227
  setAttr(el, 'data-gr-state', 'error');
186
228
  return;
187
229
  }
@@ -190,15 +232,11 @@
190
232
 
191
233
  setAttr(el, 'data-gr-state', 'loading');
192
234
 
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) {
198
- target.innerHTML = html;
235
+ fragment(src).then(function (html) {
236
+ target.replaceChildren(html);
199
237
  setAttr(el, 'data-gr-state', isOpen() ? 'open' : 'ready');
200
238
  }, function (error) {
201
- G.warn('окно: ' + src + ' не загружен: ' + (error && error.message));
239
+ G.warn('dialog: ' + src + ' not loaded: ' + (error && error.message));
202
240
  setAttr(el, 'data-gr-state', 'error');
203
241
  });
204
242
  }
@@ -244,13 +282,19 @@
244
282
  function pushHash() {
245
283
  if (!o.hash || !id() || typeof history === 'undefined' || hashIs()) return;
246
284
 
247
- try { history.pushState(null, '', '#' + id()); } catch (e) { /* file:// и прочее */ }
285
+ try { history.pushState(null, '', '#' + id()); pushed = true; } catch (e) { /* file:// и прочее */ }
248
286
  }
249
287
 
288
+ // Свой #id закрытие убирает шагом назад по истории. #id, пришедший
289
+ // со ссылкой (страница открыта сразу с окном), так не убрать: «назад»
290
+ // увёл бы с сайта. Он снимается из адреса на месте, без новой записи.
250
291
  function popHash() {
251
292
  if (!o.hash || byHistory || typeof history === 'undefined' || !hashIs()) return;
252
293
 
253
- try { history.back(); } catch (e) { /* без истории */ }
294
+ try {
295
+ if (pushed) history.back();
296
+ else history.replaceState(history.state, '', location.pathname + location.search);
297
+ } catch (e) { /* без истории */ }
254
298
  }
255
299
 
256
300
  // --- Открытие и закрытие ------------------------------------------------
@@ -291,6 +335,7 @@
291
335
  relock();
292
336
  stopMedia();
293
337
  popHash();
338
+ pushed = false;
294
339
  byHistory = false;
295
340
 
296
341
  if (el.getAttribute('data-gr-state') !== 'loading') setAttr(el, 'data-gr-state', 'ready');
@@ -356,7 +401,7 @@
356
401
  function open(target) {
357
402
  var w = instanceOf(target);
358
403
 
359
- if (!w) { G.warn('окно не найдено: ' + target); return null; }
404
+ if (!w) { G.warn('dialog not found: ' + target); return null; }
360
405
 
361
406
  return w.open();
362
407
  }
@@ -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> знает сам