@vaadin/form-layout 22.0.0-rc1 → 23.0.0-alpha2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaadin/form-layout",
3
- "version": "22.0.0-rc1",
3
+ "version": "23.0.0-alpha2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -34,16 +34,17 @@
34
34
  "dependencies": {
35
35
  "@polymer/iron-resizable-behavior": "^3.0.0",
36
36
  "@polymer/polymer": "^3.0.0",
37
- "@vaadin/component-base": "22.0.0-rc1",
38
- "@vaadin/vaadin-lumo-styles": "22.0.0-rc1",
39
- "@vaadin/vaadin-material-styles": "22.0.0-rc1",
40
- "@vaadin/vaadin-themable-mixin": "22.0.0-rc1"
37
+ "@vaadin/component-base": "23.0.0-alpha2",
38
+ "@vaadin/vaadin-lumo-styles": "23.0.0-alpha2",
39
+ "@vaadin/vaadin-material-styles": "23.0.0-alpha2",
40
+ "@vaadin/vaadin-themable-mixin": "23.0.0-alpha2"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@esm-bundle/chai": "^4.3.4",
44
+ "@vaadin/custom-field": "23.0.0-alpha2",
44
45
  "@vaadin/testing-helpers": "^0.3.2",
45
- "@vaadin/text-field": "22.0.0-rc1",
46
+ "@vaadin/text-field": "23.0.0-alpha2",
46
47
  "sinon": "^9.2.1"
47
48
  },
48
- "gitHead": "7b6f44bcd2c0fd415028ace666feeb0fedb1d540"
49
+ "gitHead": "070f586dead02ca41b66717820c647f48bf1665f"
49
50
  }
@@ -97,7 +97,16 @@ import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mix
97
97
  *
98
98
  * See [Styling Components](https://vaadin.com/docs/latest/ds/customization/styling-components) documentation.
99
99
  */
100
- declare class FormItem extends ThemableMixin(HTMLElement) {}
100
+ declare class FormItem extends ThemableMixin(HTMLElement) {
101
+ /**
102
+ * Returns a target element to add ARIA attributes to for a field.
103
+ *
104
+ * - For Vaadin field components, the method returns an element
105
+ * obtained through the `ariaTarget` property defined in `FieldMixin`.
106
+ * - In other cases, the method returns the field element itself.
107
+ */
108
+ protected _getFieldAriaTarget(field: HTMLElement): HTMLElement;
109
+ }
101
110
 
102
111
  declare global {
103
112
  interface HTMLElementTagNameMap {
@@ -4,6 +4,7 @@
4
4
  * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5
5
  */
6
6
  import { html, PolymerElement } from '@polymer/polymer/polymer-element.js';
7
+ import { addValueToAttribute, removeValueFromAttribute } from '@vaadin/field-base/src/utils.js';
7
8
  import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
8
9
 
9
10
  /**
@@ -145,12 +146,13 @@ class FormItem extends ThemableMixin(PolymerElement) {
145
146
  min-width: 0;
146
147
  }
147
148
  </style>
148
- <div id="label" part="label" on-click="_onLabelClick">
149
- <slot name="label" id="labelSlot"></slot>
149
+ <div id="label" part="label" on-click="__onLabelClick">
150
+ <slot name="label" id="labelSlot" on-slotchange="__onLabelSlotChange"></slot>
151
+ <span part="required-indicator" aria-hidden="true"></span>
150
152
  </div>
151
153
  <div id="spacing"></div>
152
154
  <div id="content">
153
- <slot id="contentSlot"></slot>
155
+ <slot id="contentSlot" on-slotchange="__onContentSlotChange"></slot>
154
156
  </div>
155
157
  `;
156
158
  }
@@ -159,17 +161,173 @@ class FormItem extends ThemableMixin(PolymerElement) {
159
161
  return 'vaadin-form-item';
160
162
  }
161
163
 
164
+ constructor() {
165
+ super();
166
+ this.__updateInvalidState = this.__updateInvalidState.bind(this);
167
+
168
+ /**
169
+ * An observer for a field node to reflect its `required` and `invalid` attributes to the component.
170
+ *
171
+ * @type {MutationObserver}
172
+ * @private
173
+ */
174
+ this.__fieldNodeObserver = new MutationObserver(() => this.__updateRequiredState(this.__fieldNode.required));
175
+
176
+ /**
177
+ * The first label node in the label slot.
178
+ *
179
+ * @type {HTMLElement | null}
180
+ * @private
181
+ */
182
+ this.__labelNode = null;
183
+
184
+ /**
185
+ * The first field node in the content slot.
186
+ *
187
+ * An element is considered a field when it has the `checkValidity` or `validate` method.
188
+ *
189
+ * @type {HTMLElement | null}
190
+ * @private
191
+ */
192
+ this.__fieldNode = null;
193
+
194
+ // Ensure every instance has unique ID
195
+ const uniqueId = (FormItem._uniqueLabelId = 1 + FormItem._uniqueLabelId || 0);
196
+ this.__labelId = `label-${this.localName}-${uniqueId}`;
197
+ }
198
+
199
+ /**
200
+ * Returns a target element to add ARIA attributes to for a field.
201
+ *
202
+ * - For Vaadin field components, the method returns an element
203
+ * obtained through the `ariaTarget` property defined in `FieldMixin`.
204
+ * - In other cases, the method returns the field element itself.
205
+ *
206
+ * @param {HTMLElement} field
207
+ * @protected
208
+ */
209
+ _getFieldAriaTarget(field) {
210
+ return field.ariaTarget || field;
211
+ }
212
+
213
+ /**
214
+ * Links the label to a field by adding the label id to
215
+ * the `aria-labelledby` attribute of the field's ARIA target element.
216
+ *
217
+ * @param {HTMLElement} field
218
+ * @private
219
+ */
220
+ __linkLabelToField(field) {
221
+ addValueToAttribute(this._getFieldAriaTarget(field), 'aria-labelledby', this.__labelId);
222
+ }
223
+
224
+ /**
225
+ * Unlinks the label from a field by removing the label id from
226
+ * the `aria-labelledby` attribute of the field's ARIA target element.
227
+ *
228
+ * @param {HTMLElement} field
229
+ * @private
230
+ */
231
+ __unlinkLabelFromField(field) {
232
+ removeValueFromAttribute(this._getFieldAriaTarget(field), 'aria-labelledby', this.__labelId);
233
+ }
234
+
162
235
  /** @private */
163
- _onLabelClick() {
164
- const firstContentElementChild = Array.prototype.find.call(
165
- this.$.contentSlot.assignedNodes(),
166
- (e) => e.nodeType === Node.ELEMENT_NODE
167
- );
168
- if (firstContentElementChild) {
169
- firstContentElementChild.focus();
170
- firstContentElementChild.click();
236
+ __onLabelClick() {
237
+ const fieldNode = this.__fieldNode;
238
+ if (fieldNode) {
239
+ fieldNode.focus();
240
+ fieldNode.click();
171
241
  }
172
242
  }
243
+
244
+ /** @private */
245
+ __getValidateFunction(field) {
246
+ return field.validate || field.checkValidity;
247
+ }
248
+
249
+ /**
250
+ * A `slotchange` event handler for the label slot.
251
+ *
252
+ * - Ensures the label id is only assigned to the first label node.
253
+ * - Ensures the label node is linked to the first field node via the `aria-labelledby` attribute
254
+ * if both nodes are provided, and unlinked otherwise.
255
+ *
256
+ * @private
257
+ */
258
+ __onLabelSlotChange() {
259
+ if (this.__labelNode) {
260
+ this.__labelNode.id = '';
261
+ this.__labelNode = null;
262
+
263
+ if (this.__fieldNode) {
264
+ this.__unlinkLabelFromField(this.__fieldNode);
265
+ }
266
+ }
267
+
268
+ const newLabelNode = this.$.labelSlot.assignedElements()[0];
269
+ if (newLabelNode) {
270
+ this.__labelNode = newLabelNode;
271
+ this.__labelNode.id = this.__labelId;
272
+
273
+ if (this.__fieldNode) {
274
+ this.__linkLabelToField(this.__fieldNode);
275
+ }
276
+ }
277
+ }
278
+
279
+ /**
280
+ * A `slotchange` event handler for the content slot.
281
+ *
282
+ * - Ensures the label node is only linked to the first field node via the `aria-labelledby` attribute.
283
+ * - Sets up an observer for the `required` attribute changes on the first field
284
+ * to reflect the attribute on the component. Ensures the observer is disconnected from the field
285
+ * as soon as it is removed or replaced by another one.
286
+ *
287
+ * @private
288
+ */
289
+ __onContentSlotChange() {
290
+ if (this.__fieldNode) {
291
+ // Discard the old field
292
+ this.__unlinkLabelFromField(this.__fieldNode);
293
+ this.__updateRequiredState(false);
294
+ this.__fieldNodeObserver.disconnect();
295
+ this.__fieldNode = null;
296
+ }
297
+
298
+ const newFieldNode = this.$.contentSlot.assignedElements().find((field) => {
299
+ return !!this.__getValidateFunction(field);
300
+ });
301
+ if (newFieldNode) {
302
+ this.__fieldNode = newFieldNode;
303
+ this.__updateRequiredState(this.__fieldNode.required);
304
+ this.__fieldNodeObserver.observe(this.__fieldNode, { attributes: true, attributeFilter: ['required'] });
305
+
306
+ if (this.__labelNode) {
307
+ this.__linkLabelToField(this.__fieldNode);
308
+ }
309
+ }
310
+ }
311
+
312
+ /** @private */
313
+ __updateRequiredState(required) {
314
+ if (required) {
315
+ this.setAttribute('required', '');
316
+ this.__fieldNode.addEventListener('blur', this.__updateInvalidState);
317
+ this.__fieldNode.addEventListener('change', this.__updateInvalidState);
318
+ } else {
319
+ this.removeAttribute('invalid');
320
+ this.removeAttribute('required');
321
+ this.__fieldNode.removeEventListener('blur', this.__updateInvalidState);
322
+ this.__fieldNode.removeEventListener('change', this.__updateInvalidState);
323
+ }
324
+ }
325
+
326
+ /** @private */
327
+ __updateInvalidState() {
328
+ const isValid = this.__getValidateFunction(this.__fieldNode).call(this.__fieldNode);
329
+ this.toggleAttribute('invalid', isValid === false);
330
+ }
173
331
  }
174
332
 
175
333
  customElements.define(FormItem.is, FormItem);
@@ -23,6 +23,24 @@ registerStyles(
23
23
  transition: color 0.4s;
24
24
  line-height: 1.333;
25
25
  }
26
+
27
+ [part='required-indicator']::after {
28
+ content: var(--lumo-required-field-indicator, '•');
29
+ transition: opacity 0.2s;
30
+ opacity: 0;
31
+ color: var(--lumo-required-field-indicator-color, var(--lumo-primary-text-color));
32
+ position: relative;
33
+ width: 1em;
34
+ text-align: center;
35
+ }
36
+
37
+ :host([required]) [part='required-indicator']::after {
38
+ opacity: 1;
39
+ }
40
+
41
+ :host([invalid]) [part='required-indicator']::after {
42
+ color: var(--lumo-required-field-indicator-color, var(--lumo-error-text-color));
43
+ }
26
44
  `,
27
45
  { moduleId: 'lumo-form-item' }
28
46
  );
@@ -14,6 +14,15 @@ registerStyles(
14
14
  margin-top: 16px;
15
15
  margin-bottom: 8px;
16
16
  }
17
+
18
+ :host([required]) [part='required-indicator']::after {
19
+ content: ' *';
20
+ color: inherit;
21
+ }
22
+
23
+ :host([invalid]) [part='label'] {
24
+ color: var(--material-error-text-color);
25
+ }
17
26
  `,
18
27
  { moduleId: 'material-form-item' }
19
28
  );