-
Notifications
You must be signed in to change notification settings - Fork 0
/
input-num.js
898 lines (867 loc) · 39 KB
/
input-num.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
export {InputNum};
import {DISABLED, VALUE, BaseElement} from "./base-element.js";
const
MAX = "max", // DOM attributes:
MIN = "min",
STEP = "step",
// custom attributes: numbers and strings first
DELAY = "delay", // millisecond delay between mousedown & spin
INTERVAL = "interval", // millisecond interval for spin
DIGITS = "digits", // Number.prototype.toFixed(digits)
UNITS = "units", // units string suffix
LOCALE = "locale", // locale string: "aa-AA", "" = user locale
NOTATION = "notation", // Intl.NumberFormat() options notation prop
CURRENCY = "currency", // ditto: currency property
// booleans:
ACCOUNTING = "accounting", // {currencySign:"accounting"}
ANY_DECIMAL = "any-decimal", // lets users input either comma or period
BLUR_CANCEL = "blur-cancel", // Blur = <Esc> key, else Blur = <Enter> key
NO_KEYS = "no-keys", // no keyboard/pad input, only spinning
NO_SPIN = "no-spin", // hide spinner, no keyboard spinning
NO_CONFIRM = "no-confirm", // hide confirm/cancel buttons
NO_SCALE = "no-scale", // don't scale the buttons to match font size
NO_WIDTH = "no-width", // don't auto-size width
NO_ALIGN = "no-align", // don't auto-align or auto-pad
NO_RESIZE = "no-resize", // don't run resize(), used during page load
NAN = "NaN", // class names:
OOB = "OoB", // Out of Bounds
BEEP = "beep",
INPUT = "input", // <input> element id, and coincidentally its tagName
SPINNER = "#spinner-", // Each def id has two or three segments:
CONFIRM = "#confirm-",
HOVER = "hover-", // second segments:
ACTIVE = "active-",
SPIN = "spin-", // for full-speed spinning
IDLE = "idle", // no third segment for idle
TOP = "top", // third segments:
BOT = "bot",
minimums = { // some attributes have hard minimum values
[DIGITS]: 0,
[DELAY]: 1,
[INTERVAL]: 1
},
code = { // KeyboardEvent.prototype.code values
enter: "Enter",
escape:"Escape",
up: "ArrowUp",
down: "ArrowDown"
},
key = { // Event types:
down: "keydown",
up: "keyup",
[code.up]: TOP, // ArrowUp = TOP button
[code.down]: BOT // ArrowDown = BOT button
},
mouse = {
over: "mouseover",
out: "mouseout",
down: "mousedown",
up: "mouseup",
click:"click",
},
event = {
blur: "blur",
focus:"focus"
},
textAlign = ["text-align","right","important"],
noAwait = true; // see https://github.com/sidewayss/html-elements/issues/8
// =============================================================================
class InputNum extends BaseElement {
#attrs; #bound; #btns; #confirmIt; #ctrls; #decimal; #erId; #hoverBtn;
#hoverOut; #input; #isBlurring; #isLoading; #isMousing; #kbSpin; #locale;
#outFocus; #padRight; #spinId; #states; #svg; #texts; #validate;
#isBlurry; // kludge for this._dom.activeElement === null
static defaults = {
[VALUE]: 0, // attribute values as numbers, not strings
[DIGITS]: 0, // defaults to integer formatting
[MAX]: Infinity,
[MIN]: -Infinity,
[STEP]: 1,
[DELAY]: 500,
[INTERVAL]:33 // ~2 frames at 60fps
};
static observedAttributes = [
VALUE, MAX, MIN, STEP, DELAY, INTERVAL, DIGITS, UNITS, LOCALE, CURRENCY,
ACCOUNTING, NOTATION, NO_KEYS, NO_SPIN, NO_CONFIRM, NO_WIDTH, NO_ALIGN,
NO_SCALE, ...BaseElement.observedAttributes
];
// =============================================================================
constructor() {
super(import.meta, InputNum, noAwait);
// #attrs caches numbers for revert on NaN
this.#attrs = Object.assign({}, InputNum.defaults);
this.#decimal = "."; // that's the universal default
this.#spinId = null;
this.#bound = { // #swapEvents adds/removes #bound events
[mouse.down]: this.#mouseDown.bind(this),
[mouse.up]: this.#mouseUp .bind(this),
[mouse.click]:this.#click .bind(this)
};
this.#locale = {currencyDisplay:"narrowSymbol"}; // constant
this.#getDigits(this.#locale);
this.#addEvent(key.down, this.#keyDown);
this.#addEvent(key.up, this.#keyUp);
this.#addEvent(mouse.over, this.#hover);
this.#addEvent(mouse.out, this.#hover);
this.#addEvent(event.focus, this.#active);
this.#addEvent(event.blur, this.#active);
const obj = {}; // #states are #input styles for each state
for (const evt of [event.blur, event.focus, mouse.over])
obj[evt] = {};
obj[mouse.out] = obj[event.blur];
this.#states = obj;
this.#isLoading = true; // prevents double-setting of values during load
if (!noAwait)
this._init();
}
//==============================================================================
// _init() exists for noAwait, part of it belongs in a connectedCallback()
_init() {
this.#input = this._dom.getElementById(INPUT);
this.#input.style.setProperty(...textAlign); // default is right-align
this.#addEvent(event.focus, this.#focus, this.#input);
this.#addEvent(event.blur, this.#blur , this.#input);
let elm = this.#input.nextElementSibling;
this.#svg = elm;
this.#btns = elm.getElementsByClassName("events");
this.#texts = elm.nextElementSibling.children;
this.#ctrls = elm.getElementById("controls");
this._use = this.#ctrls.getElementsByTagName("use")[0];
for (elm of this.#btns) {
this.#addEvent(mouse.over, this.#mouseOver, elm);
this.#addEvent(mouse.out, this.#mouseOut, elm);
}
this.#swapEvents(true);
if (noAwait) { // skipped when noAwait:
this.#input.inputMode = this.digits ? "decimal" : "numeric";
this.#input.disabled = !this.keyboards;
if (!this.autoAlign) // default set above
this.#input.style.setProperty(textAlign[0], null);
this._connected();
}
}
// _connected() is the pseudo-connectedCallback(), see BaseElement.
_connected() {
const val = this.#attrs[VALUE];
if (val > this.max || val < this.min) // clamp value, even the default
this.value = Math.max(this.min, Math.min(this.max, val));
this.#input.value = this.#getText(false, true);
this.#padRight = parseFloat(getComputedStyle(this.#input).paddingRight);
this.resize();
this.#isLoading = false;
}
// attributeChangedCallback() runs after the attribute has been set. There is
// no preventing it. For numeric attributes, #revert() restores the previous,
// valid value. Validation of string values is left to the DOM.
attributeChangedCallback(name, _, val) {
let isResize, isUpdate;
if (name in this.#attrs) { // numeric attributes
const n = this.#toNumber(val);
if (Number.isNaN(n) && name != STEP) {
this.#revert(name); // null and "" are not valid values
return; // you can't remove these attributes
} //-----------
switch (name) {
case STEP:
if (val === null)
this.#attrs[STEP] = this.#autoStep(this.#attrs[DIGITS]);
else if (n)
this.#accept(name, n);
else // can't be zero
this.#revert(name);
return;
//----------
case DIGITS: // runs twice if #revert(), but simpler
isResize = true;
isUpdate = true;
this.#getDigits(this.#locale, n);
if (this.#input) // for noAwait
this.#input.inputMode = n ? "decimal" : "numeric";
if (!this.hasAttribute(STEP)) // auto-step default:
this.#attrs[STEP] = this.#autoStep(n);
case DELAY:
case INTERVAL:
n >= minimums[name]
? this.#accept(name, n)
: this.#revert(name);
break;
default: // VALUE, MAX, MIN
const
isMax = (name == MAX),
isMin = (name == MIN);
isResize = isMax || isMin;
isUpdate = !isResize && !this.#isBlurring;
if (!isMax && n > this.max) // clamp it
this.setAttribute(name, this.getAttribute(MAX));
else if (!isMin && n < this.min)
this.setAttribute(name, this.getAttribute(MIN));
else {
this.#accept(name, n); // accept it
if (!this.#isLoading && (isMax && n < this.value
|| isMin && n > this.value))
this.setAttribute(VALUE, n); // clamp VALUE
}
}
}
else { // boolean and string attributes:
isResize = true;
switch(name) { // booleans:
case NO_KEYS:
isResize = false;
if (this.#input) // for noAwait
this.#input.disabled = (val !== null);
break;
case NO_CONFIRM:
case NO_SPIN: // assume element does not have focus
this.#swapEvents(true);
break;
case NO_ALIGN:
if (this.#input) { // for noAwait
const args = (val === null) ? textAlign : [textAlign[0], null];
this.#input.style.setProperty(...args);
}
case NO_WIDTH:
case NO_SCALE:
break;
default:
isUpdate = true;
switch(name) {
case LOCALE: // the decimal marker:
this.#decimal = (val === null)
? "." // convert "" to undefined
: Intl.NumberFormat(val || undefined)
.formatToParts(.1)
.find(p => p.type == "decimal")
.value;
break;
case ACCOUNTING:
this.#locale.currencySign = (val !== null)
? "accounting" : undefined;
case UNITS: // strings:
break;
case CURRENCY:
this.#locale.style = val ? "currency" : undefined;
case NOTATION: // convert null to undefined
this.#locale[name] = val ?? undefined;
break;
case DISABLED: // falls through
for (const elm of this.#btns)
elm.style.pointerEvents = (val === null) ? "" : "none";
default: // handled by BaseElement
super.attributeChangedCallback(name, _, val);
return;
} //-------
}
}
if (!this.#isLoading) {
if (isResize)
this.resize();
if (isUpdate) {
if (this.#input) { // for noAwait
const b = this.#inFocus;
this.#input.value = this.#getText(b, !b && this.units);
}
}
}
}
// attributeChangedCallback() helpers:
#accept(name, n) {
this.#attrs[name] = n;
}
#revert(name) {
this.setAttribute(name, this.#attrs[name]);
}
#autoStep(digits) { // using min/max to auto-step integers would be cool if
return 1 / Math.pow(10, digits); // you could ever make sense of it...
}
//==============================================================================
// Getters/setters reflect the HTML attributes, see attributeChangedCallback()
get keyboards() { return !this.hasAttribute(NO_KEYS); } // booleans:
get spins() { return !this.hasAttribute(NO_SPIN); }
get confirms() { return !this.hasAttribute(NO_CONFIRM); }
get autoWidth() { return !this.hasAttribute(NO_WIDTH); }
get autoAlign() { return !this.hasAttribute(NO_ALIGN); }
get autoResize() { return !this.hasAttribute(NO_RESIZE); }
get autoScale() { return !this.hasAttribute(NO_SCALE); }
get blurCancel() { return this.hasAttribute(BLUR_CANCEL); }
get anyDecimal() { return this.hasAttribute(ANY_DECIMAL); }
get accounting() { return this.hasAttribute(ACCOUNTING); }
get useLocale() { return this.hasAttribute(LOCALE); } // read-only
get units() { return this.getAttribute(UNITS) ?? ""; } // strings:
get locale() { return this.getAttribute(LOCALE) || undefined; }
get currency() { return this.getAttribute(CURRENCY); }
get notation() { return this.getAttribute(NOTATION); }
get text() { return this.#input.value; } // read-only
get value() { return this.#attrs[VALUE]; } // numbers:
get digits() { return this.#attrs[DIGITS]; }
get max() { return this.#attrs[MAX]; }
get min() { return this.#attrs[MIN]; }
get step() { return this.#attrs[STEP]; }
get delay() { return this.#attrs[DELAY]; }
get interval() { return this.#attrs[INTERVAL]; }
get validate() { return this.#validate; } // function:
set validate(val) {
if (val === undefined || (val instanceof Function))
this.#validate = val;
else
throw new Error("validate must be an instance of Function or undefined.");
}
set keyboards (val) { this._setBool(NO_KEYS, !val); } // booleans:
set spins (val) { this._setBool(NO_SPIN, !val); }
set confirms (val) { this._setBool(NO_CONFIRM, !val); }
set autoWidth (val) { this._setBool(NO_WIDTH, !val); }
set autoAlign (val) { this._setBool(NO_ALIGN, !val); }
set autoResize(val) { this._setBool(NO_RESIZE, !val); }
set autoScale (val) { this._setBool(NO_SCALE, !val); }
set blurCancel(val) { this._setBool(BLUR_CANCEL, val); }
set anyDecimal(val) { this._setBool(ANY_DECIMAL, val); }
set accounting(val) { this._setBool(ACCOUNTING, val); }
set units (val) { this.#setRemove(UNITS, val); } // strings:
set locale (val) { this.#setRemove(LOCALE, val); }
set currency(val) { this.#setRemove(CURRENCY, val); }
set notation(val) { this.#setRemove(NOTATION, val); }
set value (val) { this.setAttribute(VALUE, val); } // numbers:
set digits (val) { this.setAttribute(DIGITS, val); }
set max (val) { this.setAttribute(MAX, val); }
set min (val) { this.setAttribute(MIN, val); }
set step (val) { this.#setRemove (STEP, val); }
set delay (val) { this.setAttribute(DELAY, val); }
set interval(val) { this.setAttribute(INTERVAL, val); }
// #setRemove() is for non-boolean attributes that can be removed
#setRemove(attr, val) {
val === null || val === undefined
? this.removeAttribute(attr)
: this.setAttribute(attr, val);
}
//==============================================================================
// Event Handlers
// Both this and #up, #down listen for mouseover, mouseout, focus and blur.
// this handles both mouse events with #hover, and focus/blur with #active().
// #up, #down handle each event separately, handler is named after the event.
//
// mouseover and mouseout ==================================
// target is up or down <rect>: starts/stops spin, sets href
#mouseOver(evt) {
let args;
const id = evt.target.id;
if (this.#isSpinning) { // if spinning, spin the other way
if (this.#spinId >= 0)
this.#clearSpin(); // clearInterval()
args = [false, id == TOP]; // restart at full speed
}
this.#overOut(id, `${this.#getState(id)}`, args);
}
#mouseOut(evt) {
const
id = evt.relatedTarget?.id,
args = !this.#isSpinning || id == TOP || id == BOT
? undefined // don't call #spin()
: []; // cancel spinning
this.#overOut("", IDLE, args);
}
#overOut(hoverBtn, state, args) {
this.#hoverBtn = hoverBtn;
this._setHref(`${this.#getButton(this.#inFocus)}${state}`);
if (args !== undefined)
this.#spin(...args);
}
// target is this: shows/hides controls and formats #input for spinner
#hover(evt) {
const
isOver = (evt.type == mouse.over),
inFocus = this.#inFocus;
this.#hoverOut = isOver;
if (!this.#hoverBtn)
this._setHref(`${this.#getButton(inFocus)}${IDLE}`);
if (inFocus) {
if (this.confirms)
this.#showCtrls(isOver);
}
else if (this.spins) {
const
isActive = (this === document.activeElement),
either = isOver || isActive;
this.#showCtrls(either);
this.#input.value = this.#getText(false, !either);
if (!isActive)
this.#assignCSS(evt.type);
}
}
// focus and blur ============================================================
// target is this:
// #active() only runs when focusing/blurring the element and relatedTarget is
// not #input. #isMousing exits early when clicking on #input.
#active(evt) {
if (this.#isMousing || this.#hoverOut) return;
//-------------------------------------------------------------------
// If we get this far then #inFocus is false and #fur() does not run.
// #hoverOut is excluded because hovering already sets these.
const outFocus = (evt.type == event.focus);
this.#outFocus = outFocus; //% this === dom.activeElement (or not)
this.#input.value = this.#getText(false, !outFocus);
this.#assignCSS(outFocus ? mouse.over : event.blur);
this.#showCtrls(outFocus);
if (outFocus) // always idle buttons:
this._setHref(this.#getButton(false) + this.#getState());
}
// target is #input:
#focus(evt) {
// stopPropagation() here doesn't prevent #active()
if (this.#isBlurry || this.#isMousing) return;
//-------------------------------------------------
this.#fur(evt, false, CONFIRM, !this.#hoverBtn,
this.#getText(true));
}
#blur(evt) {
if (this.#isBlurry || this.#isMousing) return;
//----------------------------------------------------------------------
// The default behavior is to confirm the value onblur() when the user
// presses the Tab key or clicks elsewhere on the page. To cancel input
// onblur(), set the blur-cancel attribute. Either way, the blur event
// handler is where we "confirm it" i.e. set the value attribute.
// #confirmIt: true = Enter/OK, false = Esc/Cancel, undefined = Tab/etc.
if (this.#confirmIt ?? !this.blurCancel) {
const val = this.#validate?.(this.text) ?? this.text;
if (val !== false) { // set VALUE from keyboard input is funky
this.#isBlurring = true;
this.setAttribute(VALUE, val);
this.#isBlurring = false;
this.dispatchEvent(new Event("change"));
}
}
this.#confirmIt = undefined; // undefined falls back to !blurCancel
// When #input has the focus and the user presses Shift+Tab:
// evt.relatedTarget === this and #active does not run
// because this === document.activeElement before and after
const
showIt = (evt.relatedTarget === this) || this.#hoverOut,
value = this.#getText(false, !showIt),
type = showIt ? mouse.out : evt.type;
if (this.#fur(evt, true, SPINNER, showIt, value, type)) {
this.classList.remove(NAN);
this.classList.remove(OOB);
}
}
#fur(evt, isBlur, name, showIt, value, type = evt.type) {
this.#input.value = value;
this.#assignCSS(type);
this.#showCtrls(showIt);
this._setHref (name + this.#getState());
this.#swapEvents(isBlur); // both are focusing or blurring:
if (isBlur ? document.activeElement !== this : !this.#outFocus) {
if (evt.target) // else called by #mouseUp()
evt.stopPropagation(); // don't run #active()
this.#outFocus = !isBlur; //% but gotta set this
}
return true;
}
// mousedown, mouseup, and click ===========================================
// target is #input or up or down <rect>:
// #isMousing is because in #input the user can mousedown & drag to select,
// even trigger mouseout, and because preventDefault() doesn't prevent blur.
#mouseDown(evt) {
if (evt.target === this.#input) {
if (!this.#inFocus) { // about to get focus, not yet
this.#isMousing = true;
this.#dragEvents(true);
}
}
else if (this.#inFocus) { // clicking ok/cancel blurs #input
const id = evt.target.id;
this.#isMousing = true; // #blur() = noop
this._setHref(this.#getButton(this.#inFocus) + ACTIVE + id);
if (id == TOP && this.classList.contains(NAN))
this.classList.add(BEEP);
}
else {
this.#spin(true); // start spinning
if (this.#outFocus) // Chrome: prevent next element's
evt.preventDefault(); // text selection on double-click,
} // but allow focus to happen first
}
#mouseUp(evt) {
if (evt.currentTarget !== window) {
this.#spin(); // not worth checking if (#isSpinning)
this.classList.remove(BEEP); // ditto if (BEEP)
}
else {
let match, obj; // end a text selection drag
const
range = [],
input = this.#input,
dir = input.selectionDirection,
start = input.selectionStart,
end = input.selectionEnd,
dist = end - start,
val = input.value,
rxp = new RegExp(`[^\\d\-eE${this.#decimal}]`, "g"),
orig = [{num:start, str:val.slice(0, start)},
{num:dist, str:val.slice(start, end)}];
for (obj of orig) {
match = obj.str.match(rxp);
range.push(obj.num - (match ? match.length : 0))
}
this.#isMousing = false; // let #focus() do it's thing, #input
this.#focus(new Event(event.focus)); // might already have focus.
if (this.accounting && val[0] == "(")
++range[0];
input.setSelectionRange(range[0], range[0] + range[1], dir);
this.#dragEvents(false);
}
}
// target is up or down <rect>: confirm only, not spinner. It must check
// classList.contains(NAN) because setting #up's pointer-events to "none"
// displays text I-beam cursor.
#click(evt) {
this.#confirmIt = (evt.target.id == TOP); // TOP == OK, else Cancel
if (this.#confirmIt && this.classList.contains(NAN)) {
this.#input.focus(); // mousedown/up handles BEEP
this.#isMousing = false; // must follow #input.focus()
}
else
this.#blurMe(false);
}
// keydown and keyup ========================================================
// keyboard target is this, because the svg buttons don't receive focus.
#keyDown(evt) { // document .activeElement === this
if (this.#inFocus) // this._dom.activeElement === this.#input
switch (evt.code) {
case code.enter:
if (this.classList.contains(NAN)) // force the user to input
this.classList.add(BEEP); // a valid number or cancel.
else {
this.#confirmIt = true; // apply the user input
this.#input.blur();
}
break;
case code.escape:
this.#confirmIt = false; // user is cancelling
this.#input.blur();
default:
}
else
switch (evt.code) { // this._dom.activeElement === null
case code.enter: case code.escape:
this.#confirmIt = false; // value already confirmed
this.#blurMe(true);
return;
case code.down:
case code.up:
// Keyboard repeat rate is an OS setting that has its own delay then
// interval. Thus #spin(null), which doesn't set href or #spinId.
// Separate href for delayed image because fast keydown/up sequences
// flicker, and a delay can look just as flickery, depending on the
// timing. It looks better if the initial, pre-delay image is only
// slightly different from #spinner-idle.
const
topBot = key[evt.code],
isSpin = this.#isSpinning, // #spin() can change #isSpinning
inBounds = this.#spin(null, topBot == TOP);
if (!isSpin) {
this._setHref(`${SPINNER}key-${topBot}`);
this.#spinId = -1; // not null so #isSpinning = true
this.#kbSpin = inBounds; // kb for keyboard
}
else if (this.#kbSpin !== undefined) {
if (this.#kbSpin) // set full-speed spin image once
this._setHref(`${SPINNER}${SPIN}${topBot}`);
this.#kbSpin = undefined;
}
default:
}
}
//--------------------------------------------------------------------------
#keyUp(evt) { // Esc key never makes it this far
if (this.#inFocus) {
if (evt.code == code.enter) // any character accepted...
this.classList.remove(BEEP);
else { // ...but style erroneous values:
const n = this.#toNumber(this.text);
this.classList.toggle(NAN, Number.isNaN(n));
this.classList.toggle(OOB, n > this.max || n < this.min);
}
} // else spin it:
else if (evt.code == code.up || evt.code == code.down)
this.#spin(undefined, evt.code == code.up);
}
//==============================================================================
// this.#inFocus returns true if #input has the focus
get #inFocus() { return this.#input === this._dom.activeElement; }
// #blurMe() is because this.blur() noops in Chrome when:
// document.activeElement === this && _dom.activeElement === null
#blurMe(isKey) {
if (navigator.userAgentData) { // only Chrome-based browsers support it
this.#isBlurry = isKey; // isKey == #keyDown(), else #click()
this.#input.focus();
this.#isMousing = false; // must follow focus(), precede blur()...
this.#input.blur();
this.#isBlurry = false;
}
else {
this.#isMousing = false; // ...and only matters for #click()
this.#blur(new Event(event.blur));
this.blur();
}
}
//==============================================================================
// #swapEvents() is better than converting mousedown/mouseup into click,
// and it simplifies focus and blur handlers. But mousedown for
// #btns must persist when !isBlur so as to exit #focus/blur()
// by setting #isMousing = true.
#swapEvents(isBlur) { // isBlur == !this.#inFocus
let elm;
const
downs = this.spins || this.confirms,
spins = isBlur && this.spins,
firms = !isBlur && this.confirms,
keys = !isBlur || spins;
for (elm of this.#btns) { // <rect>: #up and #down
this.#toggleEvent(mouse.down, downs, elm);
this.#toggleEvent(mouse.up, spins, elm);
this.#toggleEvent(mouse.click, firms, elm);
}
elm = this.#input;
this.#toggleEvent(mouse.down, spins, elm);
this.#toggleEvent(mouse.up, spins, elm);
// elm = this
this.#toggleEvent(key.down, keys, this.#keyDown);
this.#toggleEvent(key.up, keys, this.#keyUp);
// Push #svg back (or front) so #input gets the mouse events (or not)
this.#svg.style.zIndex = (isBlur ? this.spins: this.confirms) ? 1 : -1;
}
#dragEvents(isDrag) {
this.#toggleEvent(mouse.up, isDrag, window);
this.#toggleEvent(mouse.up, isDrag, window);
for (const elm of [this.#input, ...this.#btns])
this.#toggleEvent(mouse.up, !isDrag, elm);
}
#toggleEvent(type, b, elmer) { // elmer is an element or an event handler
const func = b ? "addEventListener"
: "removeEventListener";
if (elmer.tagName || elmer === window)
elmer[func](type, this.#bound[type]);
else // #bound is for mousedown/up and click
this[func](type, elmer);
}
#addEvent(type, func, elm) { // helps constructor only
if (elm) // not used for mousedown/up or click
func = func.bind(this);
else
elm = this;
elm.addEventListener(type, func);
}
//==============================================================================
// this.#isSpinning gives it a name
get #isSpinning() { return this.#spinId !== null; }
// #spin() controls the spinning process
#spin(state, isUp = (this.#hoverBtn == TOP)) {
if (state === undefined) { // cancel:
if (this.#isSpinning) {
this.#clearSpin();
if (isUp !== null) { // for mouseover while spinning
const state = this.#hoverBtn ? `${HOVER}${this.#hoverBtn}`
: IDLE;
this._setHref(`${SPINNER}${state}`);
this.#spinId = null;
}
}
}
else { // spin:
let
val = this.#attrs[VALUE],
oob = this.#isOoB(val, isUp);
if (!oob) {
val += this.#attrs[STEP] * (isUp ? 1 : -1);
val = this.#validate?.(val, true) ?? val; // true for isSpinning
if (val !== false) {
oob = this.#isOoB(val, isUp);
if (oob) {
this.#clearSpin(); // stop spinning and clamp the value
val = Math.max(this.min, Math.min(this.max, val));
}
this.setAttribute(VALUE, val); // false: #input w/focus doesn't spin
this.#input.value = this.#getText(false);
const evt = new Event("change");
evt.isUp = isUp;
evt.isSpinning = true;
this.dispatchEvent(evt);
}
}
if (oob)
this.#spinId = -1; // not null, same as keyboard spin
if (state) { // start spin with initial step
const
topBot = isUp ? TOP : BOT,
now = `${SPINNER}${ACTIVE}${topBot}`,
later = `${SPINNER}${SPIN}${topBot}`;
this._setHref(now);
if (!oob) {
this.#erId = setTimeout(this._setHref.bind(this), this.delay, later);
this.#spinId = setTimeout(this.#spin.bind(this), this.delay, false, isUp);
}
}
else if (state === false) // start spinning full speed
this.#spinId = setInterval(this.#spin.bind(this), this.interval, null, isUp);
// else state === null >>>> continue spinning full speed
return !oob;
}
}
#clearSpin() {
clearInterval(this.#spinId); // interchangeable w/clearTimeout()
clearTimeout (this.#erId);
this.#erId = null;
}
#isOoB(val, isUp) { // really out of and including bounds...
return isUp ? val >= this.max : val <= this.min;
}
// =============================================================================
// resize() calculates the correct width and applies it to this.#input
resize(forceIt) {
if (!this.autoResize && !forceIt) return;
//-----------------------------------------------
let btns, diff, extra, id, isItalic, prop, style;
const
px = "px",
W = "width",
PR = "padding-right",
width = {},
states = this.#states,
isAlign = this.autoAlign,
isWidth = this.max < Infinity && this.min > -Infinity
? this.autoWidth
: false; // can't auto-size infinite min or max value
style = this.#svg.style;
if (this.spins || this.confirms) {
if (this.autoScale) { // auto lets this.clientHeight adjust
style.height = "auto";
style.height = this.clientHeight + px;
}
else
for (prop of ["height", "margin-left"])
style.removeProperty(prop);
btns = this.#svg.getBoundingClientRect().width;
if (this.autoScale) // I don't trust marginLeft in -em units
style.marginLeft = -btns + px;
}
else
btns = 0; // no spinning, no confirming = no buttons
if (isWidth || isAlign) { // get widths for max, min, units
let txt, type;
style = getComputedStyle(this.#input);
isItalic = (style.fontStyle == "italic");
for (txt of this.#texts) {
id = txt.id;
if (id != UNITS)
txt.innerHTML = this.#formatNumber(this[id]);
else if (this.units)
txt.innerHTML = this.#getUnits();
else
txt.innerHTML = "";
for (type of ["","-kerning","-size-adjust","-synthesis",
"-optical-sizing","-palette"]) {
prop = "font" + type;
txt.style[prop] = style[prop];
}
width[id] = txt.getBBox().width;
}
extra = Math.max(btns, width[UNITS]); // the rest of the width
diff = Math.max(btns - width[UNITS], 0); // 0 < width[UNITS] < svg
}
if (isWidth) {
const
chars = Math.max(width[MAX], width[MIN]), // text width w/o units
obj = {
[event.blur] : chars + extra - diff,
[event.focus]: chars + extra - btns,
[mouse.over] : chars
}
if (isItalic) // right-aligned italics often truncate
for (id in obj) // #roundEven() mitigates it by 1px
obj[id] = this.#roundEven(obj[id]);
for (id in obj)
states[id][W] = obj[id] + px;
}
else {
for (id in states)
states[id][W] = "";
}
if (isAlign) {
states[event.blur] [PR] = this.#padRight + diff + px;
states[event.focus][PR] = this.#padRight + btns + px;
states[mouse.over] [PR] = this.#padRight + extra + px;
}
else {
for (id in states)
states[id][PR] = "";
}
this.#assignCSS(event.blur); // assumes #input not focused or hovering!!
}
// #roundEven() is because italics can truncate slightly when right-aligned,
// depending on the font-family and font-size. Rounding the width
// to the nearest even number of px reduces the truncation. Why???
#roundEven(n) {
const
floor = Math.floor(n),
ceil = Math.ceil(n);
return floor % 2 ? ceil : floor;
}
//==============================================================================
// #showCtrls() shows or hides the spin or confirm buttons
#showCtrls(b) {
this.#ctrls.style.visibility = b ? "visible" : "hidden";
}
// #assignCSS() is necessary because overriding ::part requires "important"
#assignCSS(type) { // only used for width, padding-right, and text-align
const style = this.#input.style;
for (const [prop, val] of Object.entries(this.#states[type]))
style.setProperty(prop, val, "important");
}
// #getText() gets the appropriate text for the #input
#getText(inFocus, appendUnits) {
const n = this.#attrs[VALUE];
return inFocus
? this.#formatNumber(n, this.#getDigits({useGrouping:false}))
: this.#formatNumber(n)
+ (appendUnits && this.units ? this.#getUnits() : "");
}
#getDigits(obj, i = this.#attrs[DIGITS]) {
obj.maximumFractionDigits = i;
obj.minimumFractionDigits = i;
return obj;
}
// #getUnits() gets the units text: currency && units displays currency per unit
#getUnits() {
return `${this.useLocale && this.#locale.currency ? "/" : ""}${this.units}`;
}
// #formatNumber() formats a number for display as text
#formatNumber(n, options = this.#locale) {
if (n !== undefined) //!!might not be necessary anymore...
return this.useLocale
? new Intl.NumberFormat(this.locale, options).format(n)
: n.toFixed(this.#attrs[DIGITS]);
}
// #toNumber() converts String to Number
#toNumber(str) {
if (!str) // str might equal "0", but never 0
return NaN; // Number() converts "" and null to 0
else if (this.anyDecimal) // part of the locale-friendly approach
str = str.replace(",", ".");
else if (this.#decimal == ",") {
if (str.includes("."))
return NaN;
str = str.replace(",", ".");
}
return Number(str); // parseFloat() is too lenient
}
// #getButton() gets the first segment of the href id
#getButton(inFocus) {
return !inFocus ? SPINNER : CONFIRM;
}
// #getState() gets the second-third segments of the href id
#getState(id = this.#hoverBtn) {
return this.#isSpinning ? `${ACTIVE}${id}`
: id ? `${HOVER}${id}`
: IDLE; // idle only has two segments
}
}
BaseElement.define(InputNum);