@nectary/components 2.1.4 → 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 (56) hide show
  1. package/action-menu/index.js +2 -3
  2. package/action-menu-option/index.js +1 -0
  3. package/avatar/index.js +1 -1
  4. package/button/index.js +4 -5
  5. package/card/index.js +3 -2
  6. package/checkbox/index.js +28 -32
  7. package/chip/index.js +3 -4
  8. package/color-menu/index.js +3 -5
  9. package/color-swatch/index.js +1 -1
  10. package/date-picker/index.js +11 -9
  11. package/date-picker/utils.d.ts +1 -0
  12. package/date-picker/utils.js +8 -0
  13. package/dialog/index.js +3 -2
  14. package/emoji/index.js +1 -1
  15. package/emoji-picker/index.js +2 -1
  16. package/field/index.js +2 -1
  17. package/flag/index.js +1 -1
  18. package/help-tooltip/index.js +1 -0
  19. package/icon-button/index.js +4 -5
  20. package/input/index.js +374 -41
  21. package/input/types.d.ts +14 -0
  22. package/input/utils.d.ts +24 -0
  23. package/input/utils.js +302 -1
  24. package/package.json +1 -1
  25. package/pop/index.js +5 -5
  26. package/popover/index.js +1 -0
  27. package/progress-stepper/index.d.ts +11 -0
  28. package/progress-stepper/index.js +209 -0
  29. package/progress-stepper/types.d.ts +22 -0
  30. package/progress-stepper/types.js +1 -0
  31. package/progress-stepper-item/index.d.ts +12 -0
  32. package/progress-stepper-item/index.js +82 -0
  33. package/progress-stepper-item/types.d.ts +23 -0
  34. package/progress-stepper-item/types.js +1 -0
  35. package/progress-stepper-item/utils.d.ts +11 -0
  36. package/progress-stepper-item/utils.js +13 -0
  37. package/select-button/index.js +3 -4
  38. package/select-menu/index.js +3 -4
  39. package/spinner/index.js +1 -0
  40. package/stop-events/index.js +1 -0
  41. package/tabs/index.js +1 -0
  42. package/tabs-icon-option/index.js +5 -3
  43. package/tabs-option/index.js +5 -3
  44. package/tag/index.js +1 -1
  45. package/textarea/index.js +5 -2
  46. package/time-picker/index.js +1 -0
  47. package/time-picker/utils.js +2 -15
  48. package/toggle/index.js +28 -31
  49. package/tooltip/index.js +4 -3
  50. package/utils/countries.d.ts +1 -0
  51. package/utils/countries.json +487 -268
  52. package/utils/element.d.ts +1 -1
  53. package/utils/element.js +6 -6
  54. package/utils/event-target.d.ts +1 -0
  55. package/utils/event-target.js +9 -0
  56. 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,15 +14,22 @@ 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
- const shadowRoot = this.attachShadow();
27
+ const shadowRoot = this.attachShadow({
28
+ delegatesFocus: true
29
+ });
23
30
  shadowRoot.appendChild(template.content.cloneNode(true));
24
31
  this.#$input = shadowRoot.querySelector('#input');
32
+ this.#$inputMask = shadowRoot.querySelector('#input-mask');
25
33
  this.#$iconSlot = shadowRoot.querySelector('slot[name="icon"]');
26
34
  this.#$iconWrapper = shadowRoot.querySelector('#icon-wrapper');
27
35
  this.#$rightSlot = shadowRoot.querySelector('slot[name="right"]');
@@ -30,18 +38,23 @@ defineCustomElement('sinch-input', class extends NectaryElement {
30
38
  this.#$leftWrapper = shadowRoot.querySelector('#left');
31
39
  this.#$wrapper = shadowRoot.querySelector('#wrapper');
32
40
  this.#sizeContext = new Context(this.#$wrapper, 'size');
41
+ this.#controller = new AbortController();
33
42
  }
34
43
  connectedCallback() {
35
44
  super.connectedCallback();
36
45
  this.setAttribute('role', 'textbox');
37
- this.#controller = new AbortController();
46
+ if (this.#controller === null) {
47
+ this.#controller = new AbortController();
48
+ }
38
49
  const options = {
39
50
  signal: this.#controller.signal
40
51
  };
41
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);
42
56
  this.#$input.addEventListener('compositionstart', this.#onCompositionStart, options);
43
- this.#$input.addEventListener('mousedown', this.#onSelectionChange, options);
44
- this.#$input.addEventListener('keydown', this.#onSelectionChange, options);
57
+ this.#$input.addEventListener('compositionend', this.#onCompositionEnd, options);
45
58
  this.#$input.addEventListener('focus', this.#onInputFocus, options);
46
59
  this.#$input.addEventListener('blur', this.#onInputBlur, options);
47
60
  this.#$iconSlot.addEventListener('slotchange', this.#onIconSlotChange, options);
@@ -50,6 +63,9 @@ defineCustomElement('sinch-input', class extends NectaryElement {
50
63
  this.addEventListener('-change', this.#onChangeReactHandler, options);
51
64
  this.addEventListener('-focus', this.#onFocusReactHandler, options);
52
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);
53
69
  this.#sizeContext.listen(this.#controller.signal);
54
70
  subscribeContext(this, 'size', this.#onContextSize, this.#controller.signal);
55
71
  this.#onIconSlotChange();
@@ -60,9 +76,10 @@ defineCustomElement('sinch-input', class extends NectaryElement {
60
76
  disconnectedCallback() {
61
77
  super.disconnectedCallback();
62
78
  this.#controller.abort();
79
+ this.#controller = null;
63
80
  }
64
81
  static get observedAttributes() {
65
- 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'];
66
83
  }
67
84
  attributeChangedCallback(name, oldVal, newVal) {
68
85
  if (oldVal === newVal) {
@@ -79,21 +96,40 @@ defineCustomElement('sinch-input', class extends NectaryElement {
79
96
  {
80
97
  const nextVal = newVal ?? '';
81
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
+ }
82
117
  if (nextVal !== prevVal) {
83
- const prevCursorPos = this.#$input.selectionEnd;
84
- const isPrevCursorEnd = prevCursorPos === prevVal.length;
85
118
  this.#$input.value = nextVal;
86
- if (!isPrevCursorEnd) {
87
- this.#$input.setSelectionRange(this.#cursorPos, this.#cursorPos);
119
+ if (isElementFocused(this.#$input)) {
120
+ this.#$input.setSelectionRange(this.#selectionEnd, this.#selectionEnd);
88
121
  }
89
122
  }
90
- this.#onRightSlotChange();
91
123
  break;
92
124
  }
93
125
  case 'placeholder':
94
126
  {
95
- this.#$input.placeholder = newVal ?? '';
96
- updateAttribute(this, 'aria-placeholder', newVal);
127
+ this.#updatePlaceholder();
128
+ break;
129
+ }
130
+ case 'mask':
131
+ {
132
+ this.#updateMask();
97
133
  break;
98
134
  }
99
135
  case 'invalid':
@@ -144,6 +180,12 @@ defineCustomElement('sinch-input', class extends NectaryElement {
144
180
  get value() {
145
181
  return getAttribute(this, 'value', '');
146
182
  }
183
+ set mask(value) {
184
+ updateAttribute(this, 'mask', value);
185
+ }
186
+ get mask() {
187
+ return getAttribute(this, 'mask');
188
+ }
147
189
  set placeholder(value) {
148
190
  updateAttribute(this, 'placeholder', value);
149
191
  }
@@ -192,6 +234,9 @@ defineCustomElement('sinch-input', class extends NectaryElement {
192
234
  set selectionDirection(value) {
193
235
  this.#$input.selectionDirection = value;
194
236
  }
237
+ setSelectionRange(selectionStart, selectionEnd) {
238
+ this.#$input.setSelectionRange(selectionStart, selectionEnd);
239
+ }
195
240
  get focusable() {
196
241
  return true;
197
242
  }
@@ -202,15 +247,280 @@ defineCustomElement('sinch-input', class extends NectaryElement {
202
247
  this.#$input.blur();
203
248
  }
204
249
  #onCompositionStart = () => {
205
- 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
+ }
206
266
  };
207
- #onSelectionChange = () => {
208
- this.#cursorPos = this.#$input.selectionEnd;
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
+ }
209
296
  };
210
- #onInput = e => {
211
- e.stopPropagation();
212
- this.#handleInput();
297
+ #onMaskBeforeInput = e => {
298
+ this.#handleMaskBeforeInput(e.inputType, e.data);
299
+ e.preventDefault();
213
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
+ }
396
+ };
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
+ }
417
+ };
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
+ }
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
+ }
214
524
  #onContextSize = e => {
215
525
  if (this.hasAttribute('size')) {
216
526
  return;
@@ -227,24 +537,38 @@ defineCustomElement('sinch-input', class extends NectaryElement {
227
537
  }
228
538
  }
229
539
  };
230
- #handleInput() {
231
- const nextValue = this.#$input.value;
232
- const prevValue = this.value;
233
- if (prevValue !== nextValue) {
234
- const nextCursorPos = this.#$input.selectionEnd;
235
- if (!this.#isPendingDk) {
236
- this.#$input.value = prevValue;
237
- const prevCursorPos = this.#cursorPos;
238
- const isPrevCursorEnd = prevCursorPos === null || prevCursorPos === prevValue.length;
239
- if (!isPrevCursorEnd) {
240
- this.#$input.setSelectionRange(prevCursorPos, prevCursorPos);
241
- }
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
+ });
242
549
  }
243
- this.#isPendingDk = false;
244
- this.#cursorPos = nextCursorPos;
245
- this.dispatchEvent(new CustomEvent('-change', {
246
- detail: nextValue
247
- }));
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 = '';
248
572
  }
249
573
  }
250
574
  #onIconSlotChange = () => {
@@ -266,7 +590,7 @@ defineCustomElement('sinch-input', class extends NectaryElement {
266
590
  this.dispatchEvent(new CustomEvent('-blur'));
267
591
  };
268
592
  #onSizeUpdate() {
269
- if (!this.isConnected) {
593
+ if (!this.isDomConnected) {
270
594
  return;
271
595
  }
272
596
  const size = this.getAttribute('data-size') ?? DEFAULT_SIZE;
@@ -281,4 +605,13 @@ defineCustomElement('sinch-input', class extends NectaryElement {
281
605
  #onBlurReactHandler = () => {
282
606
  getReactEventHandler(this, 'on-blur')?.();
283
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
+ };
284
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 {};