@nectary/components 2.1.5 → 2.2.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 (52) hide show
  1. package/action-menu/index.js +1 -0
  2. package/action-menu-option/index.js +1 -0
  3. package/avatar/index.js +1 -1
  4. package/button/index.js +3 -2
  5. package/card/index.js +3 -2
  6. package/chip/index.js +2 -1
  7. package/color-menu/index.js +2 -2
  8. package/color-swatch/index.js +1 -1
  9. package/date-picker/index.js +11 -9
  10. package/date-picker/utils.d.ts +1 -0
  11. package/date-picker/utils.js +8 -0
  12. package/dialog/index.js +3 -2
  13. package/emoji/index.js +1 -1
  14. package/emoji-picker/index.js +2 -1
  15. package/field/index.js +1 -0
  16. package/flag/index.js +1 -1
  17. package/help-tooltip/index.js +1 -0
  18. package/icon-button/index.js +3 -2
  19. package/input/index.js +371 -40
  20. package/input/types.d.ts +14 -0
  21. package/input/utils.d.ts +24 -0
  22. package/input/utils.js +302 -1
  23. package/package.json +1 -1
  24. package/pop/index.js +5 -5
  25. package/popover/index.js +1 -0
  26. package/progress-stepper/index.d.ts +11 -0
  27. package/progress-stepper/index.js +209 -0
  28. package/progress-stepper/types.d.ts +22 -0
  29. package/progress-stepper/types.js +1 -0
  30. package/progress-stepper-item/index.d.ts +12 -0
  31. package/progress-stepper-item/index.js +82 -0
  32. package/progress-stepper-item/types.d.ts +23 -0
  33. package/progress-stepper-item/types.js +1 -0
  34. package/progress-stepper-item/utils.d.ts +11 -0
  35. package/progress-stepper-item/utils.js +13 -0
  36. package/select-button/index.js +2 -1
  37. package/select-menu/index.js +2 -1
  38. package/spinner/index.js +1 -0
  39. package/stop-events/index.js +1 -0
  40. package/tabs/index.js +1 -0
  41. package/tag/index.js +1 -1
  42. package/textarea/index.js +2 -1
  43. package/time-picker/index.js +1 -0
  44. package/time-picker/utils.js +2 -15
  45. package/tooltip/index.js +4 -3
  46. package/utils/countries.d.ts +1 -0
  47. package/utils/countries.json +487 -268
  48. package/utils/element.d.ts +1 -1
  49. package/utils/element.js +5 -5
  50. package/utils/event-target.d.ts +1 -0
  51. package/utils/event-target.js +9 -0
  52. package/utils/get-react-event-handler.js +1 -1
package/input/index.js CHANGED
@@ -1,11 +1,12 @@
1
- import { Context, defineCustomElement, getAttribute, getBooleanAttribute, getLiteralAttribute, getReactEventHandler, isAttrTrue, NectaryElement, setClass, subscribeContext, updateAttribute, updateBooleanAttribute, updateExplicitBooleanAttribute, updateLiteralAttribute } from '../utils';
1
+ import { Context, defineCustomElement, getAttribute, getBooleanAttribute, getLiteralAttribute, getReactEventHandler, isAttrTrue, isElementFocused, NectaryElement, setClass, subscribeContext, updateAttribute, updateBooleanAttribute, updateExplicitBooleanAttribute, updateLiteralAttribute } from '../utils';
2
2
  import { DEFAULT_SIZE, sizeValues } from '../utils/size';
3
- const templateHTML = '<style>:host{all:initial;display:inline-block;vertical-align:middle}#wrapper{position:relative;display:flex;flex-direction:row;align-items:center;box-sizing:border-box;border-radius:var(--sinch-local-shape-radius);width:100%;height:var(--sinch-local-size);background-color:var(--sinch-comp-input-color-default-background-initial);--sinch-local-size:var(--sinch-comp-input-size-container-m);--sinch-global-size-icon:var(--sinch-comp-input-size-icon-m);--sinch-local-shape-radius:var(--sinch-comp-input-shape-radius-size-m)}:host([data-size="l"])>#wrapper{--sinch-local-size:var(--sinch-comp-input-size-container-l);--sinch-global-size-icon:var(--sinch-comp-input-size-icon-l);--sinch-local-shape-radius:var(--sinch-comp-input-shape-radius-size-l)}:host([data-size="m"])>#wrapper{--sinch-local-size:var(--sinch-comp-input-size-container-m);--sinch-global-size-icon:var(--sinch-comp-input-size-icon-m);--sinch-local-shape-radius:var(--sinch-comp-input-shape-radius-size-m)}:host([data-size="s"])>#wrapper{--sinch-local-size:var(--sinch-comp-input-size-container-s);--sinch-global-size-icon:var(--sinch-comp-input-size-icon-s);--sinch-local-shape-radius:var(--sinch-comp-input-shape-radius-size-s)}#input{all:initial;flex:1;flex-basis:0;min-width:0;height:100%;padding:0 12px;font:var(--sinch-comp-input-font-input);color:var(--sinch-comp-input-color-default-text-initial)}#input::placeholder{font:var(--sinch-comp-input-font-placeholder);color:var(--sinch-comp-input-color-default-text-placeholder);opacity:1}#border{position:absolute;border:1px solid var(--sinch-comp-input-color-default-border-initial);border-radius:var(--sinch-local-shape-radius);inset:0;pointer-events:none}#input:disabled{color:var(--sinch-comp-input-color-disabled-text-initial);-webkit-text-fill-color:var(--sinch-comp-input-color-disabled-text-initial)}#input:disabled+#border{border-color:var(--sinch-comp-input-color-disabled-border-initial)}#input:focus+#border{border-color:var(--sinch-comp-input-color-default-border-focus);border-width:2px}:host([invalid]:not(:focus-within)) #input:enabled+#border{border-color:var(--sinch-comp-input-color-invalid-border-initial)}#input[type=password]{font-size:1.5em;letter-spacing:.1em}#icon-wrapper{position:relative;height:100%}#icon{position:absolute;display:flex;align-items:center;left:12px;top:0;bottom:0;pointer-events:none;--sinch-global-color-icon:var(--sinch-comp-input-color-default-icon-initial)}:host([disabled]) #icon{--sinch-global-color-icon:var(--sinch-comp-input-color-disabled-icon-initial)}#icon-wrapper.empty{display:none}#icon-wrapper.empty~#input{padding-left:12px}#icon-wrapper:not(.empty)~#input{padding-left:calc(var(--sinch-global-size-icon) + 20px)}#right{display:flex;flex-direction:row;align-self:stretch;align-items:center;gap:4px;padding-right:4px}#right.empty{display:none}#left{display:flex;flex-direction:row;align-self:stretch;align-items:center;gap:4px;padding-left:4px}#left.empty{display:none}</style><div id="wrapper"><div id="left"><slot name="left"></slot></div><div id="icon-wrapper"><div id="icon"><slot name="icon"></slot></div></div><input id="input" type="text"/><div id="border"></div><div id="right"><slot name="right"></slot></div></div>';
4
- import { inputTypes } from './utils';
3
+ const templateHTML = '<style>:host{all:initial;display:inline-block;vertical-align:middle}#wrapper{position:relative;display:flex;flex-direction:row;align-items:center;box-sizing:border-box;border-radius:var(--sinch-local-shape-radius);width:100%;height:var(--sinch-local-size);background-color:var(--sinch-comp-input-color-default-background-initial);--sinch-local-size:var(--sinch-comp-input-size-container-m);--sinch-global-size-icon:var(--sinch-comp-input-size-icon-m);--sinch-local-shape-radius:var(--sinch-comp-input-shape-radius-size-m)}:host([data-size="l"])>#wrapper{--sinch-local-size:var(--sinch-comp-input-size-container-l);--sinch-global-size-icon:var(--sinch-comp-input-size-icon-l);--sinch-local-shape-radius:var(--sinch-comp-input-shape-radius-size-l)}:host([data-size="m"])>#wrapper{--sinch-local-size:var(--sinch-comp-input-size-container-m);--sinch-global-size-icon:var(--sinch-comp-input-size-icon-m);--sinch-local-shape-radius:var(--sinch-comp-input-shape-radius-size-m)}:host([data-size="s"])>#wrapper{--sinch-local-size:var(--sinch-comp-input-size-container-s);--sinch-global-size-icon:var(--sinch-comp-input-size-icon-s);--sinch-local-shape-radius:var(--sinch-comp-input-shape-radius-size-s)}#input-wrapper{position:relative;flex:1;flex-basis:0;min-width:0;align-self:stretch}#input{all:initial;width:100%;height:100%;padding:0 12px;box-sizing:border-box;font:var(--sinch-comp-input-font-input);color:var(--sinch-comp-input-color-default-text-initial)}#input::placeholder{font:var(--sinch-comp-input-font-placeholder);color:var(--sinch-comp-input-color-default-text-placeholder);opacity:1}#input:disabled{color:var(--sinch-comp-input-color-disabled-text-initial);-webkit-text-fill-color:var(--sinch-comp-input-color-disabled-text-initial)}#input-mask{display:none;position:absolute;inset:0;padding:0 12px;pointer-events:none;color:var(--sinch-comp-input-color-default-text-placeholder);white-space:pre;height:fit-content;margin:auto 0;overflow:hidden}#border{position:absolute;border:1px solid var(--sinch-comp-input-color-default-border-initial);border-radius:var(--sinch-local-shape-radius);inset:0;pointer-events:none}:host([disabled]) #border{border-color:var(--sinch-comp-input-color-disabled-border-initial)}#input-wrapper:focus-within+#border{border-color:var(--sinch-comp-input-color-default-border-focus);border-width:2px}#input-mask,:host([mask]) #input{font:var(--sinch-sys-font-body-monospace-m)}:host([mask]) #input-mask{display:block}:host([invalid]:not([disabled])) #input-wrapper:not(:focus-within)+#border{border-color:var(--sinch-comp-input-color-invalid-border-initial)}#input[type=password]{font-size:1.5em;letter-spacing:.1em}#icon-wrapper{position:relative;height:100%}#icon{position:absolute;display:flex;align-items:center;left:12px;top:0;bottom:0;pointer-events:none;--sinch-global-color-icon:var(--sinch-comp-input-color-default-icon-initial)}:host([disabled]) #icon{--sinch-global-color-icon:var(--sinch-comp-input-color-disabled-icon-initial)}#icon-wrapper.empty{display:none}#icon-wrapper.empty~#input-wrapper>#input,#icon-wrapper.empty~#input-wrapper>#input-mask{padding-left:12px}#icon-wrapper:not(.empty)~#input-wrapper>#input,#icon-wrapper:not(.empty)~#input-wrapper>#input-mask{padding-left:calc(var(--sinch-global-size-icon) + 20px)}#right{display:flex;flex-direction:row;align-self:stretch;align-items:center;gap:4px;padding-right:4px}#right.empty{display:none}#left{display:flex;flex-direction:row;align-self:stretch;align-items:center;gap:4px;padding-left:4px}#left.empty{display:none}</style><div id="wrapper"><div id="left"><slot name="left"></slot></div><div id="icon-wrapper"><div id="icon"><slot name="icon"></slot></div></div><div id="input-wrapper"><div id="input-mask"></div><input id="input" type="text"/></div><div id="border"></div><div id="right"><slot name="right"></slot></div></div>';
4
+ import { deleteContentBackward, deleteContentForward, getMaskSymbols, inputTypes, insertText, beginMaskedComposition, endMaskedComposition, splitValueAndMask, getMergedValueSliced, insertFromPaste } from './utils';
5
5
  const template = document.createElement('template');
6
6
  template.innerHTML = templateHTML;
7
7
  defineCustomElement('sinch-input', class extends NectaryElement {
8
8
  #$input;
9
+ #$inputMask;
9
10
  #$iconSlot;
10
11
  #$iconWrapper;
11
12
  #$rightSlot;
@@ -13,10 +14,14 @@ defineCustomElement('sinch-input', class extends NectaryElement {
13
14
  #$leftSlot;
14
15
  #$leftWrapper;
15
16
  #$wrapper;
16
- #cursorPos = null;
17
- #isPendingDk = false;
17
+ #selectionStart = 0;
18
+ #selectionEnd = 0;
19
+ #isCompositionInProgress = false;
20
+ #compositionBeginValue = '';
21
+ #wasClearedByMask = false;
18
22
  #controller = null;
19
23
  #sizeContext;
24
+ #maskSymbols = null;
20
25
  constructor() {
21
26
  super();
22
27
  const shadowRoot = this.attachShadow({
@@ -24,6 +29,7 @@ defineCustomElement('sinch-input', class extends NectaryElement {
24
29
  });
25
30
  shadowRoot.appendChild(template.content.cloneNode(true));
26
31
  this.#$input = shadowRoot.querySelector('#input');
32
+ this.#$inputMask = shadowRoot.querySelector('#input-mask');
27
33
  this.#$iconSlot = shadowRoot.querySelector('slot[name="icon"]');
28
34
  this.#$iconWrapper = shadowRoot.querySelector('#icon-wrapper');
29
35
  this.#$rightSlot = shadowRoot.querySelector('slot[name="right"]');
@@ -32,18 +38,23 @@ defineCustomElement('sinch-input', class extends NectaryElement {
32
38
  this.#$leftWrapper = shadowRoot.querySelector('#left');
33
39
  this.#$wrapper = shadowRoot.querySelector('#wrapper');
34
40
  this.#sizeContext = new Context(this.#$wrapper, 'size');
41
+ this.#controller = new AbortController();
35
42
  }
36
43
  connectedCallback() {
37
44
  super.connectedCallback();
38
45
  this.setAttribute('role', 'textbox');
39
- this.#controller = new AbortController();
46
+ if (this.#controller === null) {
47
+ this.#controller = new AbortController();
48
+ }
40
49
  const options = {
41
50
  signal: this.#controller.signal
42
51
  };
43
52
  this.#$input.addEventListener('input', this.#onInput, options);
53
+ this.#$input.addEventListener('cut', this.#onCut, options);
54
+ this.#$input.addEventListener('copy', this.#onCopy, options);
55
+ this.#$input.addEventListener('paste', this.#onPaste, options);
44
56
  this.#$input.addEventListener('compositionstart', this.#onCompositionStart, options);
45
- this.#$input.addEventListener('mousedown', this.#onSelectionChange, options);
46
- this.#$input.addEventListener('keydown', this.#onSelectionChange, options);
57
+ this.#$input.addEventListener('compositionend', this.#onCompositionEnd, options);
47
58
  this.#$input.addEventListener('focus', this.#onInputFocus, options);
48
59
  this.#$input.addEventListener('blur', this.#onInputBlur, options);
49
60
  this.#$iconSlot.addEventListener('slotchange', this.#onIconSlotChange, options);
@@ -52,6 +63,9 @@ defineCustomElement('sinch-input', class extends NectaryElement {
52
63
  this.addEventListener('-change', this.#onChangeReactHandler, options);
53
64
  this.addEventListener('-focus', this.#onFocusReactHandler, options);
54
65
  this.addEventListener('-blur', this.#onBlurReactHandler, options);
66
+ this.addEventListener('-copy', this.#onCopyReactHandler, options);
67
+ this.addEventListener('-cut', this.#onCutReactHandler, options);
68
+ this.addEventListener('-paste', this.#onPasteReactHandler, options);
55
69
  this.#sizeContext.listen(this.#controller.signal);
56
70
  subscribeContext(this, 'size', this.#onContextSize, this.#controller.signal);
57
71
  this.#onIconSlotChange();
@@ -62,9 +76,10 @@ defineCustomElement('sinch-input', class extends NectaryElement {
62
76
  disconnectedCallback() {
63
77
  super.disconnectedCallback();
64
78
  this.#controller.abort();
79
+ this.#controller = null;
65
80
  }
66
81
  static get observedAttributes() {
67
- return ['type', 'value', 'placeholder', 'invalid', 'disabled', 'size', 'autocomplete', 'data-size', 'aria-label'];
82
+ return ['type', 'value', 'placeholder', 'mask', 'invalid', 'disabled', 'size', 'autocomplete', 'data-size', 'aria-label'];
68
83
  }
69
84
  attributeChangedCallback(name, oldVal, newVal) {
70
85
  if (oldVal === newVal) {
@@ -81,21 +96,40 @@ defineCustomElement('sinch-input', class extends NectaryElement {
81
96
  {
82
97
  const nextVal = newVal ?? '';
83
98
  const prevVal = this.#$input.value;
99
+ if (this.#wasClearedByMask) {
100
+ this.#wasClearedByMask = false;
101
+ if (nextVal.length === 0) {
102
+ break;
103
+ }
104
+ }
105
+ if (this.#maskSymbols !== null) {
106
+ const {
107
+ value,
108
+ placeholder
109
+ } = splitValueAndMask(nextVal, this.#maskSymbols);
110
+ this.#$input.value = value;
111
+ this.#$inputMask.textContent = placeholder;
112
+ if (isElementFocused(this.#$input)) {
113
+ this.#$input.setSelectionRange(this.#selectionEnd, this.#selectionEnd);
114
+ }
115
+ break;
116
+ }
84
117
  if (nextVal !== prevVal) {
85
- const prevCursorPos = this.#$input.selectionEnd;
86
- const isPrevCursorEnd = prevCursorPos === prevVal.length;
87
118
  this.#$input.value = nextVal;
88
- if (!isPrevCursorEnd) {
89
- this.#$input.setSelectionRange(this.#cursorPos, this.#cursorPos);
119
+ if (isElementFocused(this.#$input)) {
120
+ this.#$input.setSelectionRange(this.#selectionEnd, this.#selectionEnd);
90
121
  }
91
122
  }
92
- this.#onRightSlotChange();
93
123
  break;
94
124
  }
95
125
  case 'placeholder':
96
126
  {
97
- this.#$input.placeholder = newVal ?? '';
98
- updateAttribute(this, 'aria-placeholder', newVal);
127
+ this.#updatePlaceholder();
128
+ break;
129
+ }
130
+ case 'mask':
131
+ {
132
+ this.#updateMask();
99
133
  break;
100
134
  }
101
135
  case 'invalid':
@@ -146,6 +180,12 @@ defineCustomElement('sinch-input', class extends NectaryElement {
146
180
  get value() {
147
181
  return getAttribute(this, 'value', '');
148
182
  }
183
+ set mask(value) {
184
+ updateAttribute(this, 'mask', value);
185
+ }
186
+ get mask() {
187
+ return getAttribute(this, 'mask');
188
+ }
149
189
  set placeholder(value) {
150
190
  updateAttribute(this, 'placeholder', value);
151
191
  }
@@ -194,6 +234,9 @@ defineCustomElement('sinch-input', class extends NectaryElement {
194
234
  set selectionDirection(value) {
195
235
  this.#$input.selectionDirection = value;
196
236
  }
237
+ setSelectionRange(selectionStart, selectionEnd) {
238
+ this.#$input.setSelectionRange(selectionStart, selectionEnd);
239
+ }
197
240
  get focusable() {
198
241
  return true;
199
242
  }
@@ -204,15 +247,280 @@ defineCustomElement('sinch-input', class extends NectaryElement {
204
247
  this.#$input.blur();
205
248
  }
206
249
  #onCompositionStart = () => {
207
- this.#isPendingDk = true;
250
+ this.#isCompositionInProgress = true;
251
+ if (this.#maskSymbols !== null) {
252
+ const selectionStart = this.#$input.selectionStart;
253
+ this.#compositionBeginValue = this.#$input.value;
254
+ if (selectionStart === this.#$input.value.length) {
255
+ return;
256
+ }
257
+ const {
258
+ value,
259
+ placeholder
260
+ } = beginMaskedComposition(this.#$input.value, this.#maskSymbols, selectionStart);
261
+ this.#$input.value = value;
262
+ this.#$input.setSelectionRange(selectionStart, selectionStart);
263
+ this.#$inputMask.textContent = placeholder;
264
+ this.#compositionBeginValue = value;
265
+ }
266
+ };
267
+ #onCompositionEnd = e => {
268
+ this.#isCompositionInProgress = false;
269
+ if (this.#maskSymbols !== null) {
270
+ const value = this.#$input.value;
271
+ const wasValueInserted = value.length !== this.#compositionBeginValue.length;
272
+ const res = endMaskedComposition(value, e.data, this.#maskSymbols, this.#$input.selectionStart, wasValueInserted);
273
+ this.#compositionBeginValue = '';
274
+ if (res !== null) {
275
+ const {
276
+ value,
277
+ placeholder,
278
+ mergedValue,
279
+ cursorPos
280
+ } = res;
281
+ this.#$input.value = value;
282
+ this.#$input.setSelectionRange(cursorPos, cursorPos);
283
+ this.#$inputMask.textContent = placeholder;
284
+ if (mergedValue.length > 0) {
285
+ this.#selectionStart = cursorPos;
286
+ this.#selectionEnd = cursorPos;
287
+ this.#dispatchChangeEvent(mergedValue);
288
+ }
289
+ }
290
+ if ((res === null || res.mergedValue.length === 0) && this.value.length !== 0) {
291
+ this.#dispatchMaskClearChangeEvent();
292
+ }
293
+ } else {
294
+ this.#onInput();
295
+ }
296
+ };
297
+ #onMaskBeforeInput = e => {
298
+ this.#handleMaskBeforeInput(e.inputType, e.data);
299
+ e.preventDefault();
300
+ };
301
+ #handleMaskBeforeInput(inputType, data) {
302
+ const selectionStart = this.#$input.selectionStart ?? 0;
303
+ const selectionEnd = this.#$input.selectionEnd ?? 0;
304
+ let res = null;
305
+ switch (inputType) {
306
+ case 'insertText':
307
+ {
308
+ res = insertText(this.#$input.value, data, this.#maskSymbols, selectionStart, selectionEnd);
309
+ break;
310
+ }
311
+ case 'insertFromPaste':
312
+ {
313
+ res = insertFromPaste(this.#$input.value, data, this.#maskSymbols, selectionStart, selectionEnd);
314
+ break;
315
+ }
316
+ case 'deleteByCut':
317
+ case 'deleteContent':
318
+ case 'deleteContentBackward':
319
+ {
320
+ res = deleteContentBackward(this.#$input.value, this.#maskSymbols, selectionStart, selectionEnd);
321
+ break;
322
+ }
323
+ case 'deleteContentForward':
324
+ {
325
+ res = deleteContentForward(this.#$input.value, this.#maskSymbols, selectionStart, selectionEnd);
326
+ break;
327
+ }
328
+ }
329
+ if (res !== null) {
330
+ const {
331
+ value,
332
+ placeholder,
333
+ mergedValue,
334
+ cursorPos
335
+ } = res;
336
+ this.#$input.value = value;
337
+ this.#$input.setSelectionRange(cursorPos, cursorPos);
338
+ this.#$inputMask.textContent = placeholder;
339
+ if (mergedValue.length > 0) {
340
+ this.#selectionStart = cursorPos;
341
+ this.#selectionEnd = cursorPos;
342
+ this.#dispatchChangeEvent(mergedValue);
343
+ }
344
+ }
345
+ if ((res === null || res.mergedValue.length === 0) && this.value.length !== 0) {
346
+ this.#dispatchMaskClearChangeEvent();
347
+ }
348
+ }
349
+ #handleBeforeInput(inputType, data) {
350
+ const selectionStart = this.#$input.selectionStart ?? 0;
351
+ const selectionEnd = this.#$input.selectionEnd ?? 0;
352
+ switch (inputType) {
353
+ case 'insertFromPaste':
354
+ {
355
+ if (data === null) {
356
+ return;
357
+ }
358
+ const value = this.value;
359
+ const cursorPos = selectionStart + data.length;
360
+ const nextValue = value.substring(0, selectionStart) + data + value.substring(selectionEnd);
361
+ this.#selectionStart = cursorPos;
362
+ this.#selectionEnd = cursorPos;
363
+ this.#dispatchChangeEvent(nextValue);
364
+ break;
365
+ }
366
+ case 'deleteByCut':
367
+ {
368
+ const value = this.value;
369
+ const cursorPos = selectionStart;
370
+ const nextValue = value.substring(0, selectionStart) + value.substring(selectionEnd);
371
+ this.#selectionStart = cursorPos;
372
+ this.#selectionEnd = cursorPos;
373
+ this.#dispatchChangeEvent(nextValue);
374
+ break;
375
+ }
376
+ }
377
+ }
378
+ #onInput = () => {
379
+ if (this.#isCompositionInProgress) {
380
+ return;
381
+ }
382
+ if (this.#maskSymbols !== null) {
383
+ return;
384
+ }
385
+ const nextValue = this.#$input.value;
386
+ const prevValue = this.value;
387
+ if (prevValue !== nextValue) {
388
+ const nextSelectionStart = this.#$input.selectionStart;
389
+ const nextSelectionEnd = this.#$input.selectionEnd;
390
+ this.#$input.value = prevValue;
391
+ this.#$input.setSelectionRange(this.#selectionStart, this.#selectionEnd);
392
+ this.#selectionStart = nextSelectionStart;
393
+ this.#selectionEnd = nextSelectionEnd;
394
+ this.#dispatchChangeEvent(nextValue);
395
+ }
208
396
  };
209
- #onSelectionChange = () => {
210
- this.#cursorPos = this.#$input.selectionEnd;
397
+ #onMaskInputAutofillChange = () => {
398
+ const nextVal = this.#$input.value;
399
+ if (this.#maskSymbols !== null) {
400
+ const {
401
+ value,
402
+ placeholder,
403
+ mergedValue,
404
+ cursorPos
405
+ } = splitValueAndMask(nextVal, this.#maskSymbols);
406
+ this.#$input.value = value;
407
+ this.#$input.setSelectionRange(cursorPos, cursorPos);
408
+ this.#$inputMask.textContent = placeholder;
409
+ if (mergedValue.length > 0) {
410
+ this.#selectionStart = cursorPos;
411
+ this.#selectionEnd = cursorPos;
412
+ this.#dispatchChangeEvent(mergedValue);
413
+ } else {
414
+ this.#dispatchMaskClearChangeEvent();
415
+ }
416
+ }
211
417
  };
212
- #onInput = e => {
213
- e.stopPropagation();
214
- this.#handleInput();
418
+ #onCopy = e => {
419
+ const value = this.#$input.value;
420
+ const selectionStart = this.#$input.selectionStart ?? 0;
421
+ const selectionEnd = this.#$input.selectionEnd ?? 0;
422
+ if (e.clipboardData === null || selectionStart === selectionEnd) {
423
+ return;
424
+ }
425
+ const copiedValue = this.#maskSymbols === null ? value.substring(selectionStart, selectionEnd) : getMergedValueSliced(value, this.#maskSymbols, selectionStart, selectionEnd);
426
+ let replacedValue = null;
427
+ const replaceWith = value => {
428
+ replacedValue = value ?? null;
429
+ };
430
+ if (this.#maskSymbols !== null) {
431
+ e.preventDefault();
432
+ e.clipboardData.setData('text/plain', copiedValue);
433
+ }
434
+ const event = new CustomEvent('-copy', {
435
+ detail: {
436
+ value: copiedValue,
437
+ replaceWith
438
+ },
439
+ cancelable: true
440
+ });
441
+ this.dispatchEvent(event);
442
+ if (event.defaultPrevented || replacedValue !== null) {
443
+ e.preventDefault();
444
+ }
445
+ if (replacedValue !== null) {
446
+ e.clipboardData.setData('text/plain', replacedValue);
447
+ }
215
448
  };
449
+ #onCut = e => {
450
+ const value = this.#$input.value;
451
+ const selectionStart = this.#$input.selectionStart ?? 0;
452
+ const selectionEnd = this.#$input.selectionEnd ?? 0;
453
+ if (e.clipboardData === null || selectionStart === selectionEnd) {
454
+ return;
455
+ }
456
+ const copiedValue = this.#maskSymbols === null ? value.substring(selectionStart, selectionEnd) : getMergedValueSliced(value, this.#maskSymbols, selectionStart, selectionEnd);
457
+ let replacedValue = null;
458
+ const replaceWith = value => {
459
+ replacedValue = value ?? null;
460
+ };
461
+ if (this.#maskSymbols !== null) {
462
+ e.preventDefault();
463
+ e.clipboardData.setData('text/plain', copiedValue);
464
+ }
465
+ const event = new CustomEvent('-cut', {
466
+ detail: {
467
+ value: copiedValue,
468
+ replaceWith
469
+ },
470
+ cancelable: true
471
+ });
472
+ this.dispatchEvent(event);
473
+ if (event.defaultPrevented || replacedValue !== null) {
474
+ e.preventDefault();
475
+ }
476
+ if (replacedValue !== null) {
477
+ e.clipboardData.setData('text/plain', replacedValue);
478
+ if (this.#maskSymbols !== null) {
479
+ this.#handleMaskBeforeInput('deleteByCut', null);
480
+ } else {
481
+ this.#handleBeforeInput('deleteByCut', null);
482
+ }
483
+ }
484
+ };
485
+ #onPaste = e => {
486
+ const pasteValue = e.clipboardData?.getData('text/plain') ?? '';
487
+ let replacedValue = '';
488
+ const replaceWith = value => {
489
+ replacedValue = value ?? '';
490
+ };
491
+ const event = new CustomEvent('-paste', {
492
+ detail: {
493
+ value: pasteValue,
494
+ replaceWith
495
+ },
496
+ cancelable: true
497
+ });
498
+ this.dispatchEvent(event);
499
+ if (event.defaultPrevented) {
500
+ e.preventDefault();
501
+ }
502
+ if (replacedValue.length === 0) {
503
+ return;
504
+ }
505
+ e.preventDefault();
506
+ if (this.#maskSymbols !== null) {
507
+ this.#handleMaskBeforeInput('insertFromPaste', replacedValue);
508
+ } else {
509
+ this.#handleBeforeInput('insertFromPaste', replacedValue);
510
+ }
511
+ };
512
+ #dispatchMaskClearChangeEvent() {
513
+ this.#wasClearedByMask = true;
514
+ this.#dispatchChangeEvent('');
515
+ }
516
+ #dispatchChangeEvent(value) {
517
+ if (value === this.value) {
518
+ return;
519
+ }
520
+ this.dispatchEvent(new CustomEvent('-change', {
521
+ detail: value
522
+ }));
523
+ }
216
524
  #onContextSize = e => {
217
525
  if (this.hasAttribute('size')) {
218
526
  return;
@@ -229,24 +537,38 @@ defineCustomElement('sinch-input', class extends NectaryElement {
229
537
  }
230
538
  }
231
539
  };
232
- #handleInput() {
233
- const nextValue = this.#$input.value;
234
- const prevValue = this.value;
235
- if (prevValue !== nextValue) {
236
- const nextCursorPos = this.#$input.selectionEnd;
237
- if (!this.#isPendingDk) {
238
- this.#$input.value = prevValue;
239
- const prevCursorPos = this.#cursorPos;
240
- const isPrevCursorEnd = prevCursorPos === null || prevCursorPos === prevValue.length;
241
- if (!isPrevCursorEnd) {
242
- this.#$input.setSelectionRange(prevCursorPos, prevCursorPos);
243
- }
540
+ #updateMask() {
541
+ if (this.mask !== null) {
542
+ if (this.#maskSymbols === null) {
543
+ this.#$input.addEventListener('beforeinput', this.#onMaskBeforeInput, {
544
+ signal: this.#controller.signal
545
+ });
546
+ this.#$input.addEventListener('change', this.#onMaskInputAutofillChange, {
547
+ signal: this.#controller.signal
548
+ });
244
549
  }
245
- this.#isPendingDk = false;
246
- this.#cursorPos = nextCursorPos;
247
- this.dispatchEvent(new CustomEvent('-change', {
248
- detail: nextValue
249
- }));
550
+ this.#maskSymbols = getMaskSymbols(this.mask);
551
+ const {
552
+ value,
553
+ placeholder
554
+ } = splitValueAndMask(this.#$input.value, this.#maskSymbols);
555
+ this.#$input.value = value;
556
+ this.#$inputMask.textContent = placeholder;
557
+ } else {
558
+ this.#maskSymbols = null;
559
+ this.#$input.removeEventListener('beforeinput', this.#onMaskBeforeInput);
560
+ this.#$input.removeEventListener('change', this.#onMaskInputAutofillChange);
561
+ }
562
+ this.#updatePlaceholder();
563
+ }
564
+ #updatePlaceholder() {
565
+ if (this.#maskSymbols === null) {
566
+ const value = this.placeholder;
567
+ this.#$input.placeholder = value ?? '';
568
+ updateAttribute(this, 'aria-placeholder', value);
569
+ } else {
570
+ updateAttribute(this, 'aria-placeholder', null);
571
+ this.#$input.placeholder = '';
250
572
  }
251
573
  }
252
574
  #onIconSlotChange = () => {
@@ -268,7 +590,7 @@ defineCustomElement('sinch-input', class extends NectaryElement {
268
590
  this.dispatchEvent(new CustomEvent('-blur'));
269
591
  };
270
592
  #onSizeUpdate() {
271
- if (!this.isConnected) {
593
+ if (!this.isDomConnected) {
272
594
  return;
273
595
  }
274
596
  const size = this.getAttribute('data-size') ?? DEFAULT_SIZE;
@@ -283,4 +605,13 @@ defineCustomElement('sinch-input', class extends NectaryElement {
283
605
  #onBlurReactHandler = () => {
284
606
  getReactEventHandler(this, 'on-blur')?.();
285
607
  };
608
+ #onCopyReactHandler = e => {
609
+ getReactEventHandler(this, 'on-copy')?.(e);
610
+ };
611
+ #onCutReactHandler = e => {
612
+ getReactEventHandler(this, 'on-cut')?.(e);
613
+ };
614
+ #onPasteReactHandler = e => {
615
+ getReactEventHandler(this, 'on-paste')?.(e);
616
+ };
286
617
  });
package/input/types.d.ts CHANGED
@@ -1,11 +1,17 @@
1
1
  import type { TSinchElementReact } from '../types';
2
2
  import type { TSinchSize } from '../utils/size';
3
3
  export type TSinchInputType = 'text' | 'password';
4
+ export type TSinchInputClipboardEvent = CustomEvent<{
5
+ value: string;
6
+ replaceWith: (value: string) => void;
7
+ }>;
4
8
  export type TSinchInputElement = HTMLElement & {
5
9
  /** Text field type, `text` by default */
6
10
  type: TSinchInputType;
7
11
  /** Value */
8
12
  value: string;
13
+ /** Mask */
14
+ mask: string | null;
9
15
  /** Text that appears in the text field when it has no value set */
10
16
  placeholder: string | null;
11
17
  /** The HTML autocomplete attribute */
@@ -19,6 +25,7 @@ export type TSinchInputElement = HTMLElement & {
19
25
  selectionStart: number | null;
20
26
  selectionEnd: number | null;
21
27
  selectionDirection: 'forward' | 'backward' | 'none' | null;
28
+ setSelectionRange(selectionStart: number, selectionEnd: number): void;
22
29
  /** Change value event */
23
30
  addEventListener(type: '-change', listener: (e: CustomEvent<string>) => void): void;
24
31
  /** Focus event */
@@ -29,6 +36,8 @@ export type TSinchInputElement = HTMLElement & {
29
36
  setAttribute(name: 'type', value: TSinchInputType): void;
30
37
  /** Value */
31
38
  setAttribute(name: 'value', value: string): void;
39
+ /** Mask */
40
+ setAttribute(name: 'mask', value: string): void;
32
41
  /** Text that appears in the text field when it has no value set */
33
42
  setAttribute(name: 'placeholder', value: string): void;
34
43
  /** The HTML autocomplete attribute */
@@ -43,6 +52,8 @@ export type TSinchInputElement = HTMLElement & {
43
52
  export type TSinchInputReact = TSinchElementReact<TSinchInputElement> & {
44
53
  /** Controlled value, doesn't change on its own and requres an onChange-value state loop */
45
54
  value: string;
55
+ /** Mask */
56
+ mask?: string;
46
57
  /** Label that is used for a11y – might be different from `label` */
47
58
  'aria-label': string;
48
59
  /** Text field type, `text` by default */
@@ -63,4 +74,7 @@ export type TSinchInputReact = TSinchElementReact<TSinchInputElement> & {
63
74
  'on-focus'?: (e: CustomEvent<void>) => void;
64
75
  /** Blur handler */
65
76
  'on-blur'?: (e: CustomEvent<void>) => void;
77
+ 'on-cut'?: (e: TSinchInputClipboardEvent) => void;
78
+ 'on-copy'?: (e: TSinchInputClipboardEvent) => void;
79
+ 'on-paste'?: (e: TSinchInputClipboardEvent) => void;
66
80
  };
package/input/utils.d.ts CHANGED
@@ -1,2 +1,26 @@
1
1
  import type { TSinchInputType } from './types';
2
+ type TSInchInputMaskSymbolModeDigit = 0;
3
+ type TSInchInputMaskSymbolModeLetter = 1;
4
+ type TSInchInputMaskSymbolModeExact = 2;
5
+ type TSinchInputMaskSymbol = {
6
+ value: string;
7
+ mode: TSInchInputMaskSymbolModeDigit | TSInchInputMaskSymbolModeLetter | TSInchInputMaskSymbolModeExact;
8
+ placeholder: string;
9
+ };
10
+ type TSinchMaskInputResult = {
11
+ value: string;
12
+ placeholder: string;
13
+ cursorPos: number;
14
+ mergedValue: string;
15
+ };
2
16
  export declare const inputTypes: readonly TSinchInputType[];
17
+ export declare const getMaskSymbols: (maskValue: string) => TSinchInputMaskSymbol[];
18
+ export declare const deleteContentBackward: (inputValue: string, maskSymbols: readonly TSinchInputMaskSymbol[], selectionStart: number, selectionEnd: number) => TSinchMaskInputResult | null;
19
+ export declare const deleteContentForward: (inputValue: string, maskSymbols: readonly TSinchInputMaskSymbol[], selectionStart: number, selectionEnd: number) => TSinchMaskInputResult | null;
20
+ export declare const beginMaskedComposition: (inputValue: string, maskSymbols: readonly TSinchInputMaskSymbol[], selectionStart: number) => Pick<TSinchMaskInputResult, 'value' | 'placeholder'>;
21
+ export declare const endMaskedComposition: (inputValue: string, data: string, maskSymbols: readonly TSinchInputMaskSymbol[], selectionStart: number, wasValueInserted: boolean) => TSinchMaskInputResult | null;
22
+ export declare const insertText: (inputValue: string, data: string, maskSymbols: readonly TSinchInputMaskSymbol[], selectionStart: number, selectionEnd: number) => TSinchMaskInputResult | null;
23
+ export declare const insertFromPaste: (inputValue: string, data: string, maskSymbols: readonly TSinchInputMaskSymbol[], selectionStart: number, selectionEnd: number) => TSinchMaskInputResult | null;
24
+ export declare const splitValueAndMask: (inputValue: string, maskSymbols: readonly TSinchInputMaskSymbol[]) => TSinchMaskInputResult;
25
+ export declare const getMergedValueSliced: (inputValue: string, maskSymbols: readonly TSinchInputMaskSymbol[], selectionStart: number, selectionEnd: number) => string;
26
+ export {};