griffincss-ui 0.24.0-dev → 0.25.0-dev

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.
@@ -46,6 +46,22 @@
46
46
  * invalid, range — сообщения проверки; label, prev, next, year, time —
47
47
  * подписи панели.
48
48
  *
49
+ * Диапазон «с — по» (Этап 43a) — пара нативных полей, связанная атрибутом:
50
+ * <input type="date" name="from" data-gr-datetime="to: #until">
51
+ * <input type="date" name="until" id="until" data-gr-datetime>
52
+ * Одного контрола с двумя концами нет намеренно: база без скрипта — два
53
+ * нативных поля, и панель одного поля пишет одно поле. Связка строится
54
+ * на готовом: значение «с» становится min у «по», значение «по» — max
55
+ * у «с», через собственный API соседа (limit), — и проверка границ,
56
+ * сообщения и aria-disabled в панели работают без новой логики; на тач-
57
+ * устройстве системный календарь сам скроет недоступное. Авторская
58
+ * граница остаётся, если она уже; очистка и destroy() возвращают её.
59
+ * Порядок дат виджет не толкает: «по» раньше «с» — ошибка ввода, и её
60
+ * показывает проверка границ, а не молчаливая правка второго поля.
61
+ * Сверх границ — только панель: пустое поле открывается на месяце
62
+ * соседа, оба конца — aria-selected, дни между ними — data-gr-in-range
63
+ * (красит CSS панели), и range: {from, to} в detail griffin:change обоих.
64
+ *
49
65
  * Виджет пишет value контрола — расширение инварианта 7, объявленное
50
66
  * в docs/griffinjs-architecture.html («Инварианты»).
51
67
  */
@@ -209,6 +225,11 @@
209
225
  return node;
210
226
  }
211
227
 
228
+ function attr(node, name, value) {
229
+ if (value) node.setAttribute(name, value);
230
+ else node.removeAttribute(name);
231
+ }
232
+
212
233
  G.defineWidget('datetime', { needs: ['mask', 'anchor'] }, function (el, opts) {
213
234
  var o = G.merge(DEFAULTS, opts);
214
235
  var native = el.getAttribute('type');
@@ -241,11 +262,52 @@
241
262
  var view = null; // первое число показанного месяца
242
263
  var active = null; // активный день панели, 'YYYY-MM-DD'
243
264
  var last = null; // последнее ISO, о котором сказано событием
265
+ var peer = null; // второе поле пары «с — по»
266
+ var role = ''; // 'from' | 'to' — кто это поле в паре
267
+ var own = { min: el.getAttribute('min'), max: el.getAttribute('max') }; // авторские границы
244
268
 
245
269
  function iso() {
246
270
  return companion ? companion.value : el.value;
247
271
  }
248
272
 
273
+ // --- Пара «с — по» ------------------------------------------------------------
274
+
275
+ function range() {
276
+ var mine = iso();
277
+ var theirs = peer.value();
278
+
279
+ return role === 'from' ? { from: mine, to: theirs } : { from: theirs, to: mine };
280
+ }
281
+
282
+ // Граница на поле и спутнике; проверка и панель узнают о ней тем же
283
+ // путём, что о всякой другой.
284
+ function apply(name, value) {
285
+ attr(el, name, value);
286
+
287
+ if (companion) { attr(companion, name, value); check(); }
288
+ if (opened && body) render(view);
289
+ }
290
+
291
+ // Граница от соседа: авторская остаётся, если она уже; пустое
292
+ // значение возвращает авторскую.
293
+ function limit(name, value) {
294
+ var mine = own[name];
295
+
296
+ apply(name, !value ? mine : mine && (name === 'min' ? mine > value : mine < value) ? mine : value);
297
+ }
298
+
299
+ function push() {
300
+ if (peer) peer.limit(role === 'from' ? 'min' : 'max', iso());
301
+ }
302
+
303
+ function unpair() {
304
+ var name = role === 'from' ? 'max' : 'min';
305
+
306
+ peer = null;
307
+ role = '';
308
+ apply(name, own[name]);
309
+ }
310
+
249
311
  // --- Проверка ---------------------------------------------------------------
250
312
 
251
313
  function check() {
@@ -275,7 +337,8 @@
275
337
 
276
338
  if (value !== last) {
277
339
  last = value;
278
- G.emit('griffin:change', el, { value: value });
340
+ G.emit('griffin:change', el, peer ? { value: value, range: range() } : { value: value });
341
+ push();
279
342
  }
280
343
  }
281
344
 
@@ -365,6 +428,11 @@
365
428
  if (companion) open();
366
429
  }
367
430
 
431
+ // Нативное поле: значение пишет платформа, и сосед по паре узнаёт о нём здесь.
432
+ function onNativeInput() {
433
+ if (!companion) push();
434
+ }
435
+
368
436
  function onInput() {
369
437
  check();
370
438
 
@@ -522,7 +590,10 @@
522
590
  var m = base.getMonth();
523
591
  var offset = (new Date(y, m, 1).getDay() - fd + 7) % 7;
524
592
  var start = new Date(y, m, 1 - offset);
525
- var selected = dateOf(iso());
593
+ // Концы отрезка: у одиночного поля оба — его значение.
594
+ var ends = peer ? range() : { from: iso(), to: iso() };
595
+ var lo = dateOf(ends.from);
596
+ var hi = dateOf(ends.to);
526
597
  var today = isoDate(new Date());
527
598
  var min = dateOf(el.getAttribute('min'));
528
599
  var max = dateOf(el.getAttribute('max'));
@@ -543,8 +614,9 @@
543
614
  var cell = element('div', 'gr-datetime-day', { role: 'gridcell', id: panel.id + '-' + key, 'data-gr-date': key });
544
615
 
545
616
  cell.textContent = String(date.getDate());
546
- cell.setAttribute('aria-selected', key === selected ? 'true' : 'false');
617
+ cell.setAttribute('aria-selected', key === lo || key === hi ? 'true' : 'false');
547
618
 
619
+ if (lo && hi && lo < key && key < hi) cell.setAttribute('data-gr-in-range', '');
548
620
  if (key === today) cell.setAttribute('aria-current', 'date');
549
621
  if ((min && key < min) || (max && key > max)) cell.setAttribute('aria-disabled', 'true');
550
622
  if (key === active) { cell.setAttribute('data-gr-state', 'active'); descendant = cell.id; }
@@ -566,7 +638,8 @@
566
638
  opened = true;
567
639
  active = null;
568
640
 
569
- var d = dateOf(iso());
641
+ // Пустое поле пары открывается на месяце соседа.
642
+ var d = dateOf(iso()) || (peer ? dateOf(peer.value()) : '');
570
643
 
571
644
  if (body) render(d ? fromIso(d) : new Date());
572
645
  if (clock) clock.value = timeOf(iso());
@@ -682,21 +755,33 @@
682
755
 
683
756
  events.add(el, 'pointerdown', onPointerDown);
684
757
  events.add(el, 'click', onClick);
758
+ events.add(el, 'input', onNativeInput);
685
759
 
686
- return {
760
+ var api = {
687
761
  el: el,
688
762
  open: open,
689
763
  close: close,
690
764
  value: iso,
691
765
  set: function (value) {
692
766
  if (companion) write(value);
693
- else el.value = value;
767
+ else { el.value = value; push(); }
694
768
  },
769
+ // Пара: limit — граница от соседа (единственный путь, которым виджет
770
+ // пишет min/max чужого поля), pair — «other — моё „с“», unpair — развод.
771
+ limit: limit,
772
+ pair: function (other) {
773
+ peer = other;
774
+ role = 'to';
775
+ push();
776
+ },
777
+ unpair: unpair,
695
778
  get panel() { return panel; },
696
779
  destroy: function () {
697
780
  restore();
698
781
  events.removeAll();
699
782
 
783
+ if (peer) { var other = peer; unpair(); other.unpair(); }
784
+
700
785
  if (panel) {
701
786
  if (G.anchor) G.anchor.clear(panel);
702
787
  panel.remove();
@@ -704,6 +789,18 @@
704
789
  }
705
790
  }
706
791
  };
792
+
793
+ // Сосед по паре: уже поднятый — как есть, иначе поднимается здесь;
794
+ // сканер на нём потом найдёт живой экземпляр.
795
+ if (o.to && mode !== 'time') {
796
+ var target = document.querySelector(o.to);
797
+ var other = target && G.mount(target, 'datetime');
798
+
799
+ if (other) { peer = other; role = 'from'; other.pair(api); push(); }
800
+ else G.warn('datetime: поле «по» не найдено — ' + o.to);
801
+ }
802
+
803
+ return api;
707
804
  });
708
805
 
709
806
  G.datetime = { layout: layout, parse: parse, format: format };
@@ -0,0 +1,327 @@
1
+ /*
2
+ * GriffinJS — сумма с разрядами (Этап 43b).
3
+ *
4
+ * <input class="gr-input" type="text" inputmode="decimal" name="amount"
5
+ * min="0" max="1000000" data-gr-number="decimals: 2">
6
+ *
7
+ * На экране — текст с разделителями разрядов по локали документа
8
+ * («1 250 000,50» при lang="ru", «1,250,000.50» при lang="en"), число
9
+ * в точечной записи («1250000.50») — в скрытом спутнике с прежним именем:
10
+ * тот же приём, что у даты. type="number" разрядов не показывает,
11
+ * inputmode не форматирует, а маска знает только фиксированные слоты —
12
+ * поэтому это отдельное поле, а не режим маски.
13
+ *
14
+ * Разбор принимает любую запись: разделитель дроби — последний из «,»
15
+ * и «.», если это знак дроби локали или чужой знак не с тремя цифрами
16
+ * за ним («1.250.000» в ru — разряды, «1250.50» — дробь); знак разрядов
17
+ * локали дробью не бывает («1,250,000» и «1,25» в en), кроме записи,
18
+ * где разряды уже разделены пробелами («1 250 000,50»). Всё остальное,
19
+ * кроме цифр и минуса в начале, отбрасывается; минус принимается, только
20
+ * если min не задан или отрицателен. Лишние цифры дроби отбрасываются,
21
+ * не округляются: сумма — то, что набрано, а не то, что посчитано.
22
+ *
23
+ * Каретка — счётом принятых знаков слева от неё, как в маске: правки
24
+ * перехватываются в beforeinput, значение пишется целиком, Backspace
25
+ * и Delete перешагивают разделители разрядов. На композиции, автозаполнении
26
+ * и отмене виджет отступает и приводит значение к формату по input —
27
+ * единственный случай, когда каретка уходит в конец.
28
+ *
29
+ * Границы min/max с поля переезжают в спутник: там, где платформа умеет,
30
+ * сообщение о выходе за них берётся у неё, иначе — из параметра range.
31
+ * Красным (aria-invalid) — только по уходу фокуса, светлеет, как только
32
+ * значение вернулось в границы. Пустое поле — required платформы.
33
+ * Дробь дополняется нулями до decimals по уходу фокуса и при записи
34
+ * из API; спутник несёт число как есть. destroy() возвращает имя
35
+ * и число в поле и снимает всё своё.
36
+ *
37
+ * Валюта и единица — не здесь: .gr-input-addon рядом. База без скрипта —
38
+ * обычное текстовое поле: сервер получит то, что набрано, разбор на
39
+ * сервере обязателен в любом случае.
40
+ *
41
+ * Виджет пишет value и валидность контрола — расширение инварианта 7,
42
+ * объявленное в docs/griffinjs-architecture.html («Инварианты»).
43
+ */
44
+ (function (G) {
45
+ 'use strict';
46
+
47
+ var DEFAULTS = { decimals: 0, range: 'Значение вне допустимых границ' };
48
+
49
+ function lang() {
50
+ return (document.documentElement && document.documentElement.getAttribute('lang'))
51
+ || (typeof navigator !== 'undefined' && navigator.language) || 'en';
52
+ }
53
+
54
+ var known = {};
55
+
56
+ // Знаки локали: разряды и дробь — из Intl; цифры всегда латинские,
57
+ // иначе разбор не узнал бы собственный вывод. Считается раз на локаль.
58
+ function marks(locale) {
59
+ if (known[locale]) return known[locale];
60
+
61
+ var group = '';
62
+ var decimal = '.';
63
+ var nf;
64
+
65
+ try {
66
+ nf = new Intl.NumberFormat(locale, { numberingSystem: 'latn' });
67
+ } catch (e) {
68
+ nf = new Intl.NumberFormat('en');
69
+ }
70
+
71
+ var parts = nf.formatToParts(12345.6);
72
+
73
+ for (var i = 0; i < parts.length; i++) {
74
+ if (parts[i].type === 'group') group = parts[i].value;
75
+ if (parts[i].type === 'decimal') decimal = parts[i].value;
76
+ }
77
+
78
+ return (known[locale] = { group: group, decimal: decimal, nf: nf });
79
+ }
80
+
81
+ // Текст → {neg, int, frac, point, value}: value — число в точечной
82
+ // записи или пустая строка; point — знак дроби набран (дробь может быть
83
+ // ещё пустой). Ведущие нули снимаются.
84
+ function parse(text, locale, decimals, signed) {
85
+ var m = marks(locale);
86
+ var s = String(text || '');
87
+ var neg = signed && /^\s*[-\u2212]/.test(s);
88
+ var at = Math.max(s.lastIndexOf(','), s.lastIndexOf('.'));
89
+ var point = false;
90
+
91
+ if (at !== -1) {
92
+ var ch = s.charAt(at);
93
+ var after = s.slice(at + 1).replace(/\D/g, '').length;
94
+
95
+ // Знак разрядов локали — дробь, только если разряды в тексте уже
96
+ // разделены пробелами: «1 250 000,50» из русского буфера в en.
97
+ point = ch === m.decimal || (after !== 3 && (ch !== m.group || /\d[\s\u00a0\u202f]\d/.test(s)));
98
+ }
99
+
100
+ var head = point ? s.slice(0, at) : s;
101
+ var int = head.replace(/\D/g, '').replace(/^0+(?=\d)/, '');
102
+ var frac = point ? s.slice(at + 1).replace(/\D/g, '').slice(0, decimals) : '';
103
+
104
+ if (!decimals) point = false;
105
+ if (!int && frac) int = '0';
106
+
107
+ return {
108
+ neg: neg, int: int, frac: frac, point: point,
109
+ value: int ? (neg ? '-' : '') + int + (frac ? '.' + frac : '') : ''
110
+ };
111
+ }
112
+
113
+ // Разбор → текст на экране: разряды — у Intl, дробь — как набрана
114
+ // (pad — дополнена нулями до decimals).
115
+ function format(p, locale, decimals, pad) {
116
+ var m = marks(locale);
117
+ var out = p.neg ? '-' : '';
118
+
119
+ if (p.int) out += m.nf.format(Number(p.int));
120
+
121
+ var frac = p.frac;
122
+
123
+ if (pad && p.int && decimals) while (frac.length < decimals) frac += '0';
124
+ if (frac || (p.point && !pad)) out += m.decimal + frac;
125
+
126
+ return out;
127
+ }
128
+
129
+ function fire(el, type) {
130
+ try {
131
+ el.dispatchEvent(new Event(type, { bubbles: true }));
132
+ } catch (e) { /* окружение без Event */ }
133
+ }
134
+
135
+ G.defineWidget('number', function (el, opts) {
136
+ var o = G.merge(DEFAULTS, opts);
137
+ var attrs = G.recorder();
138
+ var events = G.listeners();
139
+ var locale = lang();
140
+ var decimals = o.decimals > 0 ? o.decimals : 0;
141
+ var signed = !(parseFloat(el.getAttribute('min')) >= 0);
142
+ var significant = new RegExp('[\\d\\-\\u2212' + marks(locale).decimal.replace(/[.\\]/g, '\\$&') + ']');
143
+ var companion = null;
144
+ var last = null; // последнее записанное значение на экране
145
+ var told = null; // последнее число, о котором сказано событием
146
+ var focused = null; // значение на момент фокуса — для change
147
+ var bad = false; // вне границ
148
+
149
+ // Позиция каретки за k-м принятым знаком.
150
+ function caret(text, k) {
151
+ for (var i = 0; i < text.length && k > 0; i++) if (significant.test(text.charAt(i))) k--;
152
+
153
+ return i;
154
+ }
155
+
156
+ function size(text) {
157
+ var p = parse(text, locale, decimals, signed);
158
+
159
+ return (p.neg ? 1 : 0) + p.int.length + (p.point ? 1 : 0) + p.frac.length;
160
+ }
161
+
162
+ function check() {
163
+ var value = companion.value;
164
+ var message = '';
165
+
166
+ if (value) {
167
+ if (companion.validity) {
168
+ if (!companion.validity.valid) message = companion.validationMessage || o.range;
169
+ } else {
170
+ var min = parseFloat(el.getAttribute('min'));
171
+ var max = parseFloat(el.getAttribute('max'));
172
+ var n = parseFloat(value);
173
+
174
+ if (n < min || n > max) message = o.range;
175
+ }
176
+ }
177
+
178
+ bad = !!message;
179
+ if (typeof el.setCustomValidity === 'function') el.setCustomValidity(message);
180
+ if (!bad) attrs.set(el, 'aria-invalid', null);
181
+
182
+ // О первом значении — с разметки — не сообщается: его никто не менял.
183
+ if (value !== told) {
184
+ var first = told === null;
185
+
186
+ told = value;
187
+ if (!first) G.emit('griffin:change', el, { value: value });
188
+ }
189
+ }
190
+
191
+ // Запись: текст на экране, число в спутнике, каретка за k-м знаком
192
+ // (без k каретка не трогается — при записи вне фокуса ей всё равно).
193
+ function write(text, k, pad) {
194
+ var p = parse(text, locale, decimals, signed);
195
+ var next = format(p, locale, decimals, pad);
196
+
197
+ if (el.value !== next) el.value = next;
198
+ last = next;
199
+ companion.value = p.value;
200
+ check();
201
+
202
+ if (k !== undefined && typeof el.setSelectionRange === 'function') {
203
+ var at = caret(next, k);
204
+
205
+ el.setSelectionRange(at, at);
206
+ }
207
+ }
208
+
209
+ // Позиция знака, который снимает Backspace (последний перед кареткой)
210
+ // и Delete (первый от каретки); разделители между ними перешагиваются.
211
+ function before(at) {
212
+ while (at > 0 && !significant.test(el.value.charAt(at - 1))) at--;
213
+
214
+ return at > 0 ? at - 1 : 0;
215
+ }
216
+
217
+ function after(at) {
218
+ var text = el.value;
219
+
220
+ while (at < text.length && !significant.test(text.charAt(at))) at++;
221
+
222
+ return at < text.length ? at + 1 : at;
223
+ }
224
+
225
+ function onBeforeInput(event) {
226
+ var type = event.inputType || '';
227
+
228
+ if (event.isComposing || type.indexOf('Composition') !== -1) return;
229
+
230
+ var start = el.selectionStart;
231
+ var end = el.selectionEnd;
232
+
233
+ if (typeof start !== 'number' || typeof end !== 'number') return;
234
+
235
+ var insert = '';
236
+
237
+ if (type === 'insertText' || type === 'insertFromPaste' || type === 'insertFromDrop') {
238
+ insert = event.data || (event.dataTransfer ? event.dataTransfer.getData('text') : '') || '';
239
+ } else if (type === 'deleteContentBackward') {
240
+ if (start === end) start = before(start);
241
+ } else if (type === 'deleteContentForward' || type === 'deleteByCut') {
242
+ if (start === end) end = after(end);
243
+ } else {
244
+ return;
245
+ }
246
+
247
+ event.preventDefault();
248
+
249
+ var text = el.value;
250
+ var head = text.slice(0, start) + insert;
251
+ var was = el.value;
252
+
253
+ write(head + text.slice(end), size(head));
254
+
255
+ if (el.value !== was) fire(el, 'input');
256
+ }
257
+
258
+ function onInput(event) {
259
+ if (event.isComposing) return;
260
+ if (el.value !== last) write(el.value);
261
+ }
262
+
263
+ function onCompositionEnd() {
264
+ write(el.value);
265
+ }
266
+
267
+ function onFocus() {
268
+ focused = el.value;
269
+ }
270
+
271
+ function onBlur() {
272
+ write(el.value, undefined, true);
273
+
274
+ if (bad) attrs.set(el, 'aria-invalid', 'true');
275
+ if (focused !== null && el.value !== focused) fire(el, 'change');
276
+ focused = null;
277
+ }
278
+
279
+ // Спутник: число под прежним именем и границы — платформа проверяет их сама.
280
+ companion = document.createElement('input');
281
+ companion.setAttribute('type', 'number');
282
+ companion.setAttribute('hidden', '');
283
+ companion.setAttribute('step', el.getAttribute('step') || 'any');
284
+
285
+ for (var i = 0, copy = ['name', 'min', 'max']; i < copy.length; i++) {
286
+ var v = el.getAttribute(copy[i]);
287
+
288
+ if (v !== null) companion.setAttribute(copy[i], v);
289
+ }
290
+
291
+ el.parentNode.insertBefore(companion, el.nextSibling);
292
+ attrs.set(el, 'name', null);
293
+ if (!el.getAttribute('inputmode')) attrs.set(el, 'inputmode', 'decimal');
294
+
295
+ events.add(el, 'beforeinput', onBeforeInput);
296
+ events.add(el, 'input', onInput);
297
+ events.add(el, 'compositionend', onCompositionEnd);
298
+ events.add(el, 'focus', onFocus);
299
+ events.add(el, 'blur', onBlur);
300
+
301
+ write(el.value, undefined, true);
302
+
303
+ return {
304
+ el: el,
305
+ value: function () { return companion.value; },
306
+ set: function (value) {
307
+ var was = el.value;
308
+
309
+ write(String(value), undefined, true);
310
+ if (el.value !== was) fire(el, 'input');
311
+ },
312
+ destroy: function () {
313
+ events.removeAll();
314
+
315
+ var value = companion.value;
316
+
317
+ companion.remove();
318
+ attrs.restore();
319
+ el.value = value;
320
+
321
+ if (typeof el.setCustomValidity === 'function') el.setCustomValidity('');
322
+ }
323
+ };
324
+ });
325
+
326
+ G.number = { parse: parse, format: format, marks: marks };
327
+ })(typeof window !== 'undefined' && window.GriffinJS ? window.GriffinJS : require('../core/griffinjs-core.js'));
@@ -39,6 +39,13 @@
39
39
  * удаление тега или Backspace в пустом поле снимает выбор. Значение всегда
40
40
  * в <select> — форма отправляет его как без скрипта; о смене он сообщает
41
41
  * событием change.
42
+ *
43
+ * Свободные теги (Этап 42f) — флаг free рядом с multiple: Enter на непустом
44
+ * поле без активной позиции создаёт <option selected> со значением, равным
45
+ * тексту (ключевые слова, адреса писем). Дубликата по значению нет —
46
+ * существующая позиция просто выбирается. На сервер значение уходит
47
+ * текстом, проверять его — там. Без multiple флаг смысла не имеет: текст
48
+ * поля и есть значение, — и пропускается с предупреждением.
42
49
  */
43
50
  (function (G) {
44
51
  'use strict';
@@ -54,6 +61,7 @@
54
61
  highlight: true,
55
62
  cache: true,
56
63
  multiple: false, // выбранное — теги, значение — в <select multiple>
64
+ free: false, // Enter без позиции — новый тег из текста (только с multiple)
57
65
  remove: 'Убрать {label}'
58
66
  };
59
67
 
@@ -137,6 +145,7 @@
137
145
  var choice = o.multiple ? el.querySelector('select[multiple]') : null;
138
146
 
139
147
  if (o.multiple && !choice) throw new Error('для multiple нужен <select multiple> внутри');
148
+ if (o.free && !choice) { G.warn('комбобокс: free без multiple смысла не имеет — флаг пропущен'); o.free = false; }
140
149
 
141
150
  var list = el.querySelector('[role="listbox"]');
142
151
  var created = false;
@@ -452,8 +461,12 @@
452
461
  activate(key === 'Home' ? 0 : items.length - 1, true);
453
462
  event.preventDefault();
454
463
  } else if (key === 'Enter') {
455
- if (!opened || active < 0) return;
456
- select(active);
464
+ var text = input.value.trim();
465
+
466
+ if (opened && active >= 0) select(active);
467
+ else if (o.free && text) { add(text, text); input.value = ''; close(); }
468
+ else return;
469
+
457
470
  event.preventDefault();
458
471
  } else if (key === 'Escape') {
459
472
  if (!opened) return;