@nectary/components 4.9.1 → 4.10.1

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.
package/input/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Context, defineCustomElement, getAttribute, getBooleanAttribute, getLiteralAttribute, getReactEventHandler, isAttrEqual, isAttrTrue, isElementFocused, NectaryElement, setClass, subscribeContext, updateAttribute, updateBooleanAttribute, updateLiteralAttribute } from '../utils';
2
+ import { requestSubmitForm, setFormValue } from '../utils/form';
2
3
  import { DEFAULT_SIZE, sizeValues } from '../utils/size';
3
4
  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)!important;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]:not(:placeholder-shown){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
5
  import { deleteContentBackward, deleteContentForward, getMaskSymbols, inputTypes, insertText, beginMaskedComposition, endMaskedComposition, splitValueAndMask, getMergedValueSliced, insertFromPaste } from './utils';
@@ -22,12 +23,15 @@ defineCustomElement('sinch-input', class extends NectaryElement {
22
23
  #controller = null;
23
24
  #sizeContext;
24
25
  #maskSymbols = null;
26
+ #internals;
27
+ static formAssociated = true;
25
28
  constructor() {
26
29
  super();
27
30
  const shadowRoot = this.attachShadow({
28
31
  delegatesFocus: true
29
32
  });
30
33
  shadowRoot.appendChild(template.content.cloneNode(true));
34
+ this.#internals = this.attachInternals();
31
35
  this.#$input = shadowRoot.querySelector('#input');
32
36
  this.#$inputMask = shadowRoot.querySelector('#input-mask');
33
37
  this.#$iconSlot = shadowRoot.querySelector('slot[name="icon"]');
@@ -43,12 +47,14 @@ defineCustomElement('sinch-input', class extends NectaryElement {
43
47
  connectedCallback() {
44
48
  super.connectedCallback();
45
49
  this.setAttribute('role', 'textbox');
50
+ this.#internals.role = 'textbox';
46
51
  if (this.#controller === null) {
47
52
  this.#controller = new AbortController();
48
53
  }
49
54
  const options = {
50
55
  signal: this.#controller.signal
51
56
  };
57
+ this.#$input.addEventListener('keydown', this.#onKeyDown, options);
52
58
  this.#$input.addEventListener('input', this.#onInput, options);
53
59
  this.#$input.addEventListener('cut', this.#onCut, options);
54
60
  this.#$input.addEventListener('copy', this.#onCopy, options);
@@ -80,11 +86,53 @@ defineCustomElement('sinch-input', class extends NectaryElement {
80
86
  this.#controller.abort();
81
87
  this.#controller = null;
82
88
  }
89
+ formAssociatedCallback() {
90
+ setFormValue(this.#internals, this.#$input.value);
91
+ }
92
+ formResetCallback() {
93
+ this.#$input.value = '';
94
+ setFormValue(this.#internals, '');
95
+ }
96
+ formStateRestoreCallback(state) {
97
+ if (this.#internals.form === null || getBooleanAttribute(this.#internals.form, 'data-form-state-restore') === false) {
98
+ return;
99
+ }
100
+ if (state !== null) {
101
+ const value = typeof state === 'string' ? state : state.get(this.name);
102
+ this.#$input.value = value?.toString() ?? '';
103
+ setFormValue(this.#internals, value?.toString() ?? '');
104
+ }
105
+ }
106
+ #onKeyDown = e => {
107
+ const form = this.#internals.form;
108
+ if (form === null) {
109
+ return;
110
+ }
111
+ if (form.disabled === true) {
112
+ return;
113
+ }
114
+ if (e.key === 'Enter') {
115
+ const submitSelectors = ['sinch-button[form-type="submit"]'];
116
+ const formSubmitters = Array.from(form.querySelectorAll(submitSelectors.join(',')));
117
+ const formSubmitter = formSubmitters.find(submitter => !submitter.disabled) ?? null;
118
+ if (formSubmitter !== null) {
119
+ requestSubmitForm(form, formSubmitter);
120
+ }
121
+ }
122
+ };
83
123
  static get observedAttributes() {
84
- return ['type', 'value', 'placeholder', 'mask', 'invalid', 'disabled', 'size', 'autocomplete', 'autofocus', 'data-size', 'aria-label'];
124
+ return ['name', 'type', 'value', 'placeholder', 'mask', 'invalid', 'disabled', 'size', 'autocomplete', 'autofocus', 'data-size', 'aria-label'];
85
125
  }
86
126
  attributeChangedCallback(name, oldVal, newVal) {
87
127
  switch (name) {
128
+ case 'name':
129
+ {
130
+ if (isAttrEqual(oldVal, newVal)) {
131
+ return;
132
+ }
133
+ updateAttribute(this.#$input, 'name', newVal);
134
+ break;
135
+ }
88
136
  case 'type':
89
137
  {
90
138
  updateLiteralAttribute(this.#$input, inputTypes, 'type', newVal);
@@ -107,6 +155,7 @@ defineCustomElement('sinch-input', class extends NectaryElement {
107
155
  placeholder
108
156
  } = splitValueAndMask(nextVal, this.#maskSymbols);
109
157
  this.#$input.value = value;
158
+ setFormValue(this.#internals, value);
110
159
  this.#$inputMask.textContent = placeholder;
111
160
  if (isElementFocused(this.#$input)) {
112
161
  this.#setSelectionRange(this.#selectionEnd, this.#selectionEnd);
@@ -115,6 +164,7 @@ defineCustomElement('sinch-input', class extends NectaryElement {
115
164
  }
116
165
  if (nextVal !== prevVal) {
117
166
  this.#$input.value = nextVal;
167
+ setFormValue(this.#internals, nextVal);
118
168
  if (isElementFocused(this.#$input)) {
119
169
  this.#setSelectionRange(this.#selectionEnd, this.#selectionEnd);
120
170
  }
@@ -139,6 +189,7 @@ defineCustomElement('sinch-input', class extends NectaryElement {
139
189
  const isInvalid = isAttrTrue(newVal);
140
190
  this.ariaInvalid = isInvalid.toString();
141
191
  this.#$input.ariaInvalid = this.ariaInvalid;
192
+ this.#internals.ariaInvalid = this.ariaInvalid;
142
193
  updateBooleanAttribute(this, name, isInvalid);
143
194
  break;
144
195
  }
@@ -180,10 +231,17 @@ defineCustomElement('sinch-input', class extends NectaryElement {
180
231
  case 'aria-label':
181
232
  {
182
233
  this.#$input.ariaLabel = newVal;
234
+ this.#internals.ariaLabel = newVal;
183
235
  break;
184
236
  }
185
237
  }
186
238
  }
239
+ set name(value) {
240
+ updateAttribute(this, 'name', value);
241
+ }
242
+ get name() {
243
+ return getAttribute(this, 'name', '');
244
+ }
187
245
  set type(value) {
188
246
  updateAttribute(this, 'type', value);
189
247
  }
@@ -411,11 +469,14 @@ defineCustomElement('sinch-input', class extends NectaryElement {
411
469
  }
412
470
  const nextValue = this.#$input.value;
413
471
  const prevValue = this.value;
472
+ setFormValue(this.#internals, nextValue);
414
473
  if (prevValue !== nextValue) {
415
474
  const nextSelectionStart = this.#$input.selectionStart;
416
475
  const nextSelectionEnd = this.#$input.selectionEnd;
417
- this.#$input.value = prevValue;
418
- this.#setSelectionRange(this.#selectionStart, this.#selectionEnd);
476
+ if (this.hasAttribute('value')) {
477
+ this.#$input.value = prevValue;
478
+ this.#setSelectionRange(this.#selectionStart, this.#selectionEnd);
479
+ }
419
480
  this.#selectionStart = nextSelectionStart;
420
481
  this.#selectionEnd = nextSelectionEnd;
421
482
  this.#dispatchChangeEvent(nextValue);
@@ -595,10 +656,12 @@ defineCustomElement('sinch-input', class extends NectaryElement {
595
656
  if (this.#maskSymbols === null) {
596
657
  const value = this.placeholder;
597
658
  this.#$input.placeholder = value ?? '';
659
+ this.#internals.ariaPlaceholder = value ?? '';
598
660
  updateAttribute(this, 'aria-placeholder', value);
599
661
  } else {
600
662
  updateAttribute(this, 'aria-placeholder', null);
601
663
  this.#$input.placeholder = '';
664
+ this.#internals.ariaPlaceholder = '';
602
665
  }
603
666
  }
604
667
  #onIconSlotChange = () => {
package/input/types.d.ts CHANGED
@@ -6,8 +6,10 @@ export type TSinchInputClipboardEvent = CustomEvent<{
6
6
  replaceWith: (value: string) => void;
7
7
  }>;
8
8
  export type TSinchInputProps = {
9
+ /** Identification for uncontrolled form submissions */
10
+ name?: string;
9
11
  /** Controlled value, doesn't change on its own and requres an onChange-value state loop */
10
- value: string;
12
+ value?: string;
11
13
  /** Mask */
12
14
  mask?: string | null;
13
15
  /** Label that is used for a11y – might be different from `label` */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nectary/components",
3
- "version": "4.9.1",
3
+ "version": "4.10.1",
4
4
  "files": [
5
5
  "**/*/*.css",
6
6
  "**/*/*.json",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@babel/runtime": "^7.22.15",
23
- "@nectary/assets": "2.3.1"
23
+ "@nectary/assets": "2.4.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@babel/cli": "^7.22.15",
@@ -34,6 +34,6 @@
34
34
  "typescript": "^5.2.2"
35
35
  },
36
36
  "peerDependencies": {
37
- "@nectary/theme-base": "1.4.2"
37
+ "@nectary/theme-base": "1.4.3"
38
38
  }
39
39
  }
package/radio/index.js CHANGED
@@ -1,14 +1,20 @@
1
1
  import { defineCustomElement, getAttribute, getBooleanAttribute, getReactEventHandler, getTargetByAttribute, isAttrTrue, NectaryElement, updateAttribute, updateBooleanAttribute } from '../utils';
2
+ import { setFormValue } from '../utils/form';
2
3
  const templateHTML = '<style>:host{display:block}#wrapper{display:flex;flex-direction:var(--sinch-comp-radio-direction,column);gap:var(--sinch-comp-radio-gap,8px);box-sizing:border-box;width:100%}</style><div id="wrapper"><slot></slot></div>';
3
4
  const template = document.createElement('template');
4
5
  template.innerHTML = templateHTML;
5
6
  defineCustomElement('sinch-radio', class extends NectaryElement {
6
7
  #$slot;
7
8
  #controller = null;
9
+ #internals;
10
+ static formAssociated = true;
8
11
  constructor() {
9
12
  super();
10
- const shadowRoot = this.attachShadow();
13
+ const shadowRoot = this.attachShadow({
14
+ delegatesFocus: true
15
+ });
11
16
  shadowRoot.appendChild(template.content.cloneNode(true));
17
+ this.#internals = this.attachInternals();
12
18
  this.#$slot = shadowRoot.querySelector('slot');
13
19
  }
14
20
  connectedCallback() {
@@ -20,6 +26,7 @@ defineCustomElement('sinch-radio', class extends NectaryElement {
20
26
  signal
21
27
  };
22
28
  this.setAttribute('role', 'radiogroup');
29
+ this.#internals.role = 'radiogroup';
23
30
  this.#$slot.addEventListener('slotchange', this.#onSlotChange, options);
24
31
  this.#$slot.addEventListener('keydown', this.#onOptionKeyDown, options);
25
32
  this.#$slot.addEventListener('click', this.#onOptionClick, options);
@@ -29,9 +36,32 @@ defineCustomElement('sinch-radio', class extends NectaryElement {
29
36
  this.#controller.abort();
30
37
  this.#controller = null;
31
38
  }
39
+ formAssociatedCallback() {
40
+ setFormValue(this.#internals, this.value);
41
+ }
42
+ formResetCallback() {
43
+ this.value = '';
44
+ setFormValue(this.#internals, '');
45
+ }
46
+ formStateRestoreCallback(state) {
47
+ if (this.#internals.form === null || getBooleanAttribute(this.#internals.form, 'data-form-state-restore') === false) {
48
+ return;
49
+ }
50
+ if (state !== null) {
51
+ const value = typeof state === 'string' ? state : state.get(this.name);
52
+ this.value = value?.toString() ?? '';
53
+ setFormValue(this.#internals, value?.toString() ?? '');
54
+ }
55
+ }
32
56
  static get observedAttributes() {
33
57
  return ['value', 'invalid'];
34
58
  }
59
+ set name(value) {
60
+ updateAttribute(this, 'name', value);
61
+ }
62
+ get name() {
63
+ return getAttribute(this, 'name', '');
64
+ }
35
65
  set value(value) {
36
66
  updateAttribute(this, 'value', value);
37
67
  }
@@ -49,6 +79,7 @@ defineCustomElement('sinch-radio', class extends NectaryElement {
49
79
  case 'value':
50
80
  {
51
81
  this.#onValueChange(newVal ?? '');
82
+ setFormValue(this.#internals, newVal ?? '');
52
83
  break;
53
84
  }
54
85
  case 'invalid':
package/radio/types.d.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import type { NectaryComponentReactByType, NectaryComponentVanillaByType } from '../types';
2
2
  export type TSinchRadioProps = {
3
- value: string;
3
+ /** Identification for uncontrolled form submissions */
4
+ name?: string;
5
+ /** Value */
6
+ value?: string;
7
+ /** Invalid state */
4
8
  invalid?: boolean;
9
+ /** Label that is used for a11y – might be different from `label` */
5
10
  'aria-label': string;
6
11
  };
7
12
  export type TSinchRadioEvents = {
@@ -3,6 +3,7 @@ import '../icon';
3
3
  import '../text';
4
4
  import { isSelectMenuOption } from '../select-menu-option/utils';
5
5
  import { attrValueToPixels, defineCustomElement, getAttribute, getBooleanAttribute, unpackCsv, getFirstCsvValue, getIntegerAttribute, getReactEventHandler, isAttrTrue, NectaryElement, updateAttribute, updateBooleanAttribute, updateCsv, updateExplicitBooleanAttribute, updateIntegerAttribute, debounceTimeout, setClass, subscribeContext, hasClass, isTargetEqual } from '../utils';
6
+ import { CSVToFormData, setFormValue } from '../utils/form';
6
7
  const templateHTML = '<style>:host{display:block;outline:0}#listbox{overflow-y:auto;max-height:var(--sinch-comp-select-menu-font-max-height)}#search{display:none;margin:10px}#search.active{display:block}#search-clear:not(.active){display:none}#not-found{display:flex;align-items:center;justify-content:center;width:100%;height:30px;margin-bottom:10px;pointer-events:none;user-select:none;--sinch-comp-text-font:var(--sinch-comp-select-menu-font-not-found-text);--sinch-global-color-text:var(--sinch-comp-select-menu-color-default-not-found-text-initial)}#not-found:not(.active){display:none}::slotted(.hidden){display:none}::slotted(sinch-title){padding:8px 16px;--sinch-global-color-text:var(--sinch-comp-select-menu-color-default-title-initial)}</style><sinch-input id="search" size="s" placeholder="Search"><sinch-icon icons-version="2" name="magnifying-glass" id="icon-search" slot="icon"></sinch-icon><sinch-button id="search-clear" slot="right"><sinch-icon icons-version="2" name="fa-xmark" slot="icon"></sinch-icon></sinch-button></sinch-input><div id="not-found"><sinch-text type="m">No results</sinch-text></div><div id="listbox" role="presentation"><slot></slot></div>';
7
8
  const ITEM_HEIGHT = 40;
8
9
  const NUM_ITEMS_SEARCH = 7;
@@ -16,11 +17,14 @@ defineCustomElement('sinch-select-menu', class extends NectaryElement {
16
17
  #$notFound;
17
18
  #controller = null;
18
19
  #searchDebounce;
20
+ #internals;
19
21
  #userManagedSearch = false;
22
+ static formAssociated = true;
20
23
  constructor() {
21
24
  super();
22
25
  const shadowRoot = this.attachShadow();
23
26
  shadowRoot.appendChild(template.content.cloneNode(true));
27
+ this.#internals = this.attachInternals();
24
28
  this.#$optionSlot = shadowRoot.querySelector('slot');
25
29
  this.#$listbox = shadowRoot.querySelector('#listbox');
26
30
  this.#$search = shadowRoot.querySelector('#search');
@@ -34,6 +38,7 @@ defineCustomElement('sinch-select-menu', class extends NectaryElement {
34
38
  signal: this.#controller.signal
35
39
  };
36
40
  this.setAttribute('role', 'listbox');
41
+ this.#internals.role = 'listbox';
37
42
  this.tabIndex = 0;
38
43
  this.addEventListener('keydown', this.#onListboxKeyDown, options);
39
44
  this.addEventListener('focus', this.#onFocus, options);
@@ -53,6 +58,23 @@ defineCustomElement('sinch-select-menu', class extends NectaryElement {
53
58
  this.#controller.abort();
54
59
  this.#controller = null;
55
60
  }
61
+ formAssociatedCallback() {
62
+ setFormValue(this.#internals, CSVToFormData(this.name, this.value));
63
+ }
64
+ formResetCallback() {
65
+ this.value = '';
66
+ setFormValue(this.#internals, '');
67
+ }
68
+ formStateRestoreCallback(state) {
69
+ if (this.#internals.form === null || getBooleanAttribute(this.#internals.form, 'data-form-state-restore') === false) {
70
+ return;
71
+ }
72
+ if (state !== null) {
73
+ const value = typeof state === 'string' ? state : state.get(this.name);
74
+ this.value = value?.toString() ?? '';
75
+ setFormValue(this.#internals, CSVToFormData(this.name, value?.toString() ?? ''));
76
+ }
77
+ }
56
78
  static get observedAttributes() {
57
79
  return ['value', 'rows', 'multiple', 'search-value', 'search-placeholder', 'search-autocomplete'];
58
80
  }
@@ -62,6 +84,7 @@ defineCustomElement('sinch-select-menu', class extends NectaryElement {
62
84
  {
63
85
  this.#onValueChange(this.value);
64
86
  updateExplicitBooleanAttribute(this, 'aria-multiselectable', isAttrTrue(newVal));
87
+ this.#internals.ariaMultiSelectable = isAttrTrue(newVal).toString();
65
88
  break;
66
89
  }
67
90
  case 'search-autocomplete':
@@ -99,6 +122,12 @@ defineCustomElement('sinch-select-menu', class extends NectaryElement {
99
122
  }
100
123
  }
101
124
  }
125
+ set name(value) {
126
+ updateAttribute(this, 'name', value);
127
+ }
128
+ get name() {
129
+ return getAttribute(this, 'name', '');
130
+ }
102
131
  set value(value) {
103
132
  updateAttribute(this, 'value', value);
104
133
  }
@@ -265,12 +294,18 @@ defineCustomElement('sinch-select-menu', class extends NectaryElement {
265
294
  const isChecked = !getBooleanAttribute($option, 'disabled') && values.includes(getAttribute($option, 'value', ''));
266
295
  updateBooleanAttribute($option, 'data-checked', isChecked);
267
296
  }
297
+ const formData = new FormData();
298
+ values.forEach(value => {
299
+ formData.append(this.name, value);
300
+ });
301
+ setFormValue(this.#internals, formData);
268
302
  } else {
269
303
  const value = getFirstCsvValue(csv);
270
304
  for (const $option of this.#getOptionElements()) {
271
305
  const isChecked = !getBooleanAttribute($option, 'disabled') && value === getAttribute($option, 'value', '');
272
306
  updateBooleanAttribute($option, 'data-checked', isChecked);
273
307
  }
308
+ setFormValue(this.#internals, value ?? '');
274
309
  }
275
310
  }
276
311
  #getFirstOption() {
@@ -1,5 +1,7 @@
1
1
  import type { NectaryComponentReactByType, NectaryComponentVanillaByType } from '../types';
2
2
  export type TSinchSelectMenuProps = {
3
+ /** Identification for uncontrolled form submissions */
4
+ name?: string;
3
5
  /** Selected value, CSV when multiple */
4
6
  value: string;
5
7
  /** How many rows to show and scroll the rest */
package/textarea/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Context, defineCustomElement, getAttribute, getBooleanAttribute, getIntegerAttribute, getReactEventHandler, getRect, hasClass, isAttrEqual, isAttrTrue, NectaryElement, setClass, updateAttribute, updateBooleanAttribute } from '../utils';
2
+ import { setFormValue } from '../utils/form';
2
3
  import { DEFAULT_SIZE } from '../utils/size';
3
4
  const templateHTML = '<style>:host{display:block}#wrapper{display:flex;flex-direction:column;position:relative;width:100%;box-sizing:border-box;background-color:var(--sinch-comp-textarea-color-default-background-initial);border-radius:var(--sinch-local-shape-radius);padding-right:2px;overflow:hidden;--sinch-local-shape-radius:var(--sinch-comp-textarea-shape-radius)}#input{all:initial;display:block;font:var(--sinch-comp-textarea-font-input);color:var(--sinch-comp-textarea-color-default-text-initial);resize:none;white-space:pre-wrap;overflow-wrap:break-word;padding:8px 10px 8px 12px;border:none;box-sizing:border-box}#input::placeholder{color:var(--sinch-comp-textarea-color-default-text-placeholder);opacity:1}#input:disabled{color:var(--sinch-comp-textarea-color-disabled-text-initial);-webkit-text-fill-color:var(--sinch-comp-textarea-color-disabled-text-initial)}#border{position:absolute;border:1px solid var(--sinch-comp-textarea-color-default-border-initial);border-radius:var(--sinch-local-shape-radius);inset:0;pointer-events:none}:host([invalid]) #border{border-color:var(--sinch-comp-textarea-color-invalid-border-initial)}#input:focus+#border{border-color:var(--sinch-comp-textarea-color-default-border-focus);border-width:2px}#input:disabled+#border{border-color:var(--sinch-comp-textarea-color-disabled-border-initial)}#bottom{display:flex;flex-direction:row;align-items:center;gap:8px;padding:12px 4px 4px}#bottom.empty{display:none}:host([resizable]) #bottom{padding-right:calc(var(--sinch-comp-textarea-size-resize-handle) + 4px)}#resize-handle{display:none;position:absolute;width:var(--sinch-comp-textarea-size-resize-handle);height:var(--sinch-comp-textarea-size-resize-handle);bottom:0;right:0;cursor:ns-resize}:host([resizable]) #resize-handle{display:block}#resize-icon{display:block;pointer-events:none;fill:var(--sinch-comp-textarea-color-default-border-initial)}</style><div id="wrapper"><textarea id="input"></textarea><div id="border"></div><div id="bottom"><slot name="bottom"></slot><div id="resize-handle"><svg id="resize-icon" width="16" height="16"><path d="m14.833 4.724-9.61 9.61-.942-.944 9.61-9.609.942.943ZM15.443 10 10.5 14.943 9.557 14 14.5 9.057l.943.943Z"/></svg></div></div></div>';
4
5
  const template = document.createElement('template');
@@ -15,12 +16,15 @@ defineCustomElement('sinch-textarea', class extends NectaryElement {
15
16
  #prevContentHeight = 0;
16
17
  #dragStartY = 0;
17
18
  #intersectionObserver = null;
19
+ #internals;
20
+ static formAssociated = true;
18
21
  constructor() {
19
22
  super();
20
23
  const shadowRoot = this.attachShadow({
21
24
  delegatesFocus: true
22
25
  });
23
26
  shadowRoot.appendChild(template.content.cloneNode(true));
27
+ this.#internals = this.attachInternals();
24
28
  this.#$input = shadowRoot.querySelector('#input');
25
29
  this.#$bottomSlot = shadowRoot.querySelector('slot[name="bottom"]');
26
30
  this.#$bottomWrapper = shadowRoot.querySelector('#bottom');
@@ -34,7 +38,9 @@ defineCustomElement('sinch-textarea', class extends NectaryElement {
34
38
  signal: this.#controller.signal
35
39
  };
36
40
  this.setAttribute('role', 'textbox');
41
+ this.#internals.role = 'textbox';
37
42
  this.ariaMultiLine = 'true';
43
+ this.#internals.ariaMultiLine = 'true';
38
44
  this.#$input.addEventListener('input', this.#onInput, options);
39
45
  this.#$input.addEventListener('compositionstart', this.#onCompositionStart, options);
40
46
  this.#$input.addEventListener('mousedown', this.#onSelectionChange, options);
@@ -60,6 +66,23 @@ defineCustomElement('sinch-textarea', class extends NectaryElement {
60
66
  this.#intersectionObserver = null;
61
67
  }
62
68
  }
69
+ formAssociatedCallback() {
70
+ setFormValue(this.#internals, this.#$input.value);
71
+ }
72
+ formResetCallback() {
73
+ this.#$input.value = '';
74
+ setFormValue(this.#internals, '');
75
+ }
76
+ formStateRestoreCallback(state) {
77
+ if (this.#internals.form === null || getBooleanAttribute(this.#internals.form, 'data-form-state-restore') === false) {
78
+ return;
79
+ }
80
+ if (state !== null) {
81
+ const value = typeof state === 'string' ? state : state.get(this.name);
82
+ this.#$input.value = value?.toString() ?? '';
83
+ setFormValue(this.#internals, value?.toString() ?? '');
84
+ }
85
+ }
63
86
  static get observedAttributes() {
64
87
  return ['value', 'placeholder', 'invalid', 'disabled', 'rows', 'minrows', 'resizable'];
65
88
  }
@@ -74,6 +97,7 @@ defineCustomElement('sinch-textarea', class extends NectaryElement {
74
97
  const isPrevCursorEnd = prevCursorPos === prevVal.length;
75
98
  const isShrinkingContent = nextVal.length < prevVal.length;
76
99
  this.#$input.value = nextVal;
100
+ setFormValue(this.#internals, nextVal);
77
101
  if (!this.resizable) {
78
102
  if (isShrinkingContent) {
79
103
  this.#$input.style.removeProperty('height');
@@ -94,6 +118,7 @@ defineCustomElement('sinch-textarea', class extends NectaryElement {
94
118
  {
95
119
  this.#$input.placeholder = newVal ?? '';
96
120
  updateAttribute(this, 'aria-placeholder', newVal);
121
+ this.#internals.ariaPlaceholder = newVal ?? '';
97
122
  break;
98
123
  }
99
124
  case 'invalid':
@@ -103,6 +128,7 @@ defineCustomElement('sinch-textarea', class extends NectaryElement {
103
128
  }
104
129
  const isInvalid = isAttrTrue(newVal);
105
130
  this.ariaInvalid = isInvalid.toString();
131
+ this.#internals.ariaInvalid = isInvalid.toString();
106
132
  updateBooleanAttribute(this, 'invalid', isInvalid);
107
133
  break;
108
134
  }
@@ -140,6 +166,12 @@ defineCustomElement('sinch-textarea', class extends NectaryElement {
140
166
  }
141
167
  }
142
168
  }
169
+ set name(value) {
170
+ updateAttribute(this, 'name', value);
171
+ }
172
+ get name() {
173
+ return getAttribute(this, 'name', '');
174
+ }
143
175
  set value(value) {
144
176
  updateAttribute(this, 'value', value);
145
177
  }
@@ -250,6 +282,7 @@ defineCustomElement('sinch-textarea', class extends NectaryElement {
250
282
  e.stopPropagation();
251
283
  const nextValue = this.#$input.value;
252
284
  const prevValue = this.value;
285
+ setFormValue(this.#internals, nextValue);
253
286
  if (prevValue !== nextValue) {
254
287
  const nextCursorPos = this.#$input.selectionEnd;
255
288
  if (!this.#isPendingDk) {
@@ -1,5 +1,7 @@
1
1
  import type { NectaryComponentReactByType, NectaryComponentVanillaByType } from '../types';
2
2
  export type TSinchTextareaProps = {
3
+ /** Identification for uncontrolled form submissions */
4
+ name?: string;
3
5
  /** Value */
4
6
  value: string;
5
7
  /** Text that appears in the text field when it has no value set */
@@ -0,0 +1,8 @@
1
+ import type { NectaryComponentVanilla } from '../types';
2
+ export declare const setFormValue: (internals: ElementInternals, value: File | string | FormData | null) => void;
3
+ /**
4
+ * The ElementInternals API currently does not support web components as form submitters,
5
+ * so we need to create a native button and copy form-related options to it.
6
+ */
7
+ export declare const requestSubmitForm: (form: HTMLFormElement, submitter: NectaryComponentVanilla<"sinch-button">) => void;
8
+ export declare const CSVToFormData: (name: string, csv: string) => "" | FormData;
package/utils/form.js ADDED
@@ -0,0 +1,36 @@
1
+ export const setFormValue = (internals, value) => {
2
+ let formValue = value ?? '';
3
+ if (formValue instanceof FormData && [...formValue.keys()].length === 0) {
4
+ formValue = '';
5
+ }
6
+ if (formValue instanceof File && formValue.size === 0) {
7
+ formValue = '';
8
+ }
9
+ if (internals.form !== null) {
10
+ internals.setFormValue(formValue);
11
+ }
12
+ };
13
+ export const requestSubmitForm = (form, submitter) => {
14
+ const submitterProxy = document.createElement('button');
15
+ submitterProxy.style.display = 'none';
16
+ submitterProxy.type = submitter.formType;
17
+ Array.from(submitter.attributes).filter(attr => attr.name.startsWith('aria-')).forEach(attr => {
18
+ submitterProxy.setAttribute(attr.name, attr.value);
19
+ });
20
+ form.appendChild(submitterProxy);
21
+ try {
22
+ form.requestSubmit(submitterProxy);
23
+ } finally {
24
+ form.removeChild(submitterProxy);
25
+ }
26
+ };
27
+ export const CSVToFormData = (name, csv) => {
28
+ if (csv.length === 0) {
29
+ return '';
30
+ }
31
+ const formData = new FormData();
32
+ csv.split(',').forEach(value => {
33
+ formData.append(name, value);
34
+ });
35
+ return formData;
36
+ };