Skip to content

Commit 702a8a8

Browse files
authored
refactor: extract form-layout logic into reusable mixins (#8342)
1 parent cfb8cb6 commit 702a8a8

10 files changed

+752
-672
lines changed

packages/form-layout/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"polymer"
3636
],
3737
"dependencies": {
38+
"@open-wc/dedupe-mixin": "^1.3.0",
3839
"@polymer/polymer": "^3.0.0",
3940
"@vaadin/a11y-base": "24.7.0-alpha1",
4041
"@vaadin/component-base": "24.7.0-alpha1",
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* @license
3+
* Copyright (c) 2017 - 2024 Vaadin Ltd.
4+
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5+
*/
6+
import type { Constructor } from '@open-wc/dedupe-mixin';
7+
8+
/**
9+
* A mixin providing common form-item functionality.
10+
*/
11+
export declare function FormItemMixin<T extends Constructor<HTMLElement>>(base: T): Constructor<FormItemMixinClass> & T;
12+
13+
export declare class FormItemMixinClass {
14+
/**
15+
* Returns a target element to add ARIA attributes to for a field.
16+
*
17+
* - For Vaadin field components, the method returns an element
18+
* obtained through the `ariaTarget` property defined in `FieldMixin`.
19+
* - In other cases, the method returns the field element itself.
20+
*/
21+
protected _getFieldAriaTarget(field: HTMLElement): HTMLElement;
22+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* @license
3+
* Copyright (c) 2017 - 2024 Vaadin Ltd.
4+
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
5+
*/
6+
import { addValueToAttribute, removeValueFromAttribute } from '@vaadin/component-base/src/dom-utils.js';
7+
import { generateUniqueId } from '@vaadin/component-base/src/unique-id-utils.js';
8+
9+
/**
10+
* @polymerMixin
11+
*/
12+
export const FormItemMixin = (superClass) =>
13+
class extends superClass {
14+
constructor() {
15+
super();
16+
this.__updateInvalidState = this.__updateInvalidState.bind(this);
17+
18+
/**
19+
* An observer for a field node to reflect its `required` and `invalid` attributes to the component.
20+
*
21+
* @type {MutationObserver}
22+
* @private
23+
*/
24+
this.__fieldNodeObserver = new MutationObserver(() => this.__updateRequiredState(this.__fieldNode.required));
25+
26+
/**
27+
* The first label node in the label slot.
28+
*
29+
* @type {HTMLElement | null}
30+
* @private
31+
*/
32+
this.__labelNode = null;
33+
34+
/**
35+
* The first field node in the content slot.
36+
*
37+
* An element is considered a field when it has the `checkValidity` or `validate` method.
38+
*
39+
* @type {HTMLElement | null}
40+
* @private
41+
*/
42+
this.__fieldNode = null;
43+
}
44+
45+
/**
46+
* Returns a target element to add ARIA attributes to for a field.
47+
*
48+
* - For Vaadin field components, the method returns an element
49+
* obtained through the `ariaTarget` property defined in `FieldMixin`.
50+
* - In other cases, the method returns the field element itself.
51+
*
52+
* @param {HTMLElement} field
53+
* @protected
54+
*/
55+
_getFieldAriaTarget(field) {
56+
return field.ariaTarget || field;
57+
}
58+
59+
/**
60+
* Links the label to a field by adding the label id to
61+
* the `aria-labelledby` attribute of the field's ARIA target element.
62+
*
63+
* @param {HTMLElement} field
64+
* @private
65+
*/
66+
__linkLabelToField(field) {
67+
addValueToAttribute(this._getFieldAriaTarget(field), 'aria-labelledby', this.__labelId);
68+
}
69+
70+
/**
71+
* Unlinks the label from a field by removing the label id from
72+
* the `aria-labelledby` attribute of the field's ARIA target element.
73+
*
74+
* @param {HTMLElement} field
75+
* @private
76+
*/
77+
__unlinkLabelFromField(field) {
78+
removeValueFromAttribute(this._getFieldAriaTarget(field), 'aria-labelledby', this.__labelId);
79+
}
80+
81+
/** @private */
82+
__onLabelClick() {
83+
const fieldNode = this.__fieldNode;
84+
if (fieldNode) {
85+
fieldNode.focus();
86+
fieldNode.click();
87+
}
88+
}
89+
90+
/** @private */
91+
__getValidateFunction(field) {
92+
return field.validate || field.checkValidity;
93+
}
94+
95+
/**
96+
* A `slotchange` event handler for the label slot.
97+
*
98+
* - Ensures the label id is only assigned to the first label node.
99+
* - Ensures the label node is linked to the first field node via the `aria-labelledby` attribute
100+
* if both nodes are provided, and unlinked otherwise.
101+
*
102+
* @private
103+
*/
104+
__onLabelSlotChange() {
105+
if (this.__labelNode) {
106+
this.__labelNode = null;
107+
108+
if (this.__fieldNode) {
109+
this.__unlinkLabelFromField(this.__fieldNode);
110+
}
111+
}
112+
113+
const newLabelNode = this.$.labelSlot.assignedElements()[0];
114+
if (newLabelNode) {
115+
this.__labelNode = newLabelNode;
116+
117+
if (this.__labelNode.id) {
118+
// The new label node already has an id. Let's use it.
119+
this.__labelId = this.__labelNode.id;
120+
} else {
121+
// The new label node doesn't have an id yet. Generate a unique one.
122+
this.__labelId = `label-${this.localName}-${generateUniqueId()}`;
123+
this.__labelNode.id = this.__labelId;
124+
}
125+
126+
if (this.__fieldNode) {
127+
this.__linkLabelToField(this.__fieldNode);
128+
}
129+
}
130+
}
131+
132+
/**
133+
* A `slotchange` event handler for the content slot.
134+
*
135+
* - Ensures the label node is only linked to the first field node via the `aria-labelledby` attribute.
136+
* - Sets up an observer for the `required` attribute changes on the first field
137+
* to reflect the attribute on the component. Ensures the observer is disconnected from the field
138+
* as soon as it is removed or replaced by another one.
139+
*
140+
* @private
141+
*/
142+
__onContentSlotChange() {
143+
if (this.__fieldNode) {
144+
// Discard the old field
145+
this.__unlinkLabelFromField(this.__fieldNode);
146+
this.__updateRequiredState(false);
147+
this.__fieldNodeObserver.disconnect();
148+
this.__fieldNode = null;
149+
}
150+
151+
const fieldNodes = this.$.contentSlot.assignedElements();
152+
if (fieldNodes.length > 1) {
153+
console.warn(
154+
`WARNING: Since Vaadin 23, placing multiple fields directly to a <vaadin-form-item> is deprecated.
155+
Please wrap fields with a <vaadin-custom-field> instead.`,
156+
);
157+
}
158+
159+
const newFieldNode = fieldNodes.find((field) => {
160+
return !!this.__getValidateFunction(field);
161+
});
162+
if (newFieldNode) {
163+
this.__fieldNode = newFieldNode;
164+
this.__updateRequiredState(this.__fieldNode.required);
165+
this.__fieldNodeObserver.observe(this.__fieldNode, { attributes: true, attributeFilter: ['required'] });
166+
167+
if (this.__labelNode) {
168+
this.__linkLabelToField(this.__fieldNode);
169+
}
170+
}
171+
}
172+
173+
/** @private */
174+
__updateRequiredState(required) {
175+
if (required) {
176+
this.setAttribute('required', '');
177+
this.__fieldNode.addEventListener('blur', this.__updateInvalidState);
178+
this.__fieldNode.addEventListener('change', this.__updateInvalidState);
179+
} else {
180+
this.removeAttribute('invalid');
181+
this.removeAttribute('required');
182+
this.__fieldNode.removeEventListener('blur', this.__updateInvalidState);
183+
this.__fieldNode.removeEventListener('change', this.__updateInvalidState);
184+
}
185+
}
186+
187+
/** @private */
188+
__updateInvalidState() {
189+
const isValid = this.__getValidateFunction(this.__fieldNode).call(this.__fieldNode);
190+
this.toggleAttribute('invalid', isValid === false);
191+
}
192+
};

packages/form-layout/src/vaadin-form-item.d.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
55
*/
66
import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
7+
import { FormItemMixin } from './vaadin-form-item-mixin.js';
78

89
/**
910
* `<vaadin-form-item>` is a Web Component providing labelled form item wrapper
@@ -84,16 +85,7 @@ import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mix
8485
*
8586
* See [Styling Components](https://vaadin.com/docs/latest/styling/styling-components) documentation.
8687
*/
87-
declare class FormItem extends ThemableMixin(HTMLElement) {
88-
/**
89-
* Returns a target element to add ARIA attributes to for a field.
90-
*
91-
* - For Vaadin field components, the method returns an element
92-
* obtained through the `ariaTarget` property defined in `FieldMixin`.
93-
* - In other cases, the method returns the field element itself.
94-
*/
95-
protected _getFieldAriaTarget(field: HTMLElement): HTMLElement;
96-
}
88+
declare class FormItem extends FormItemMixin(ThemableMixin(HTMLElement)) {}
9789

9890
declare global {
9991
interface HTMLElementTagNameMap {

0 commit comments

Comments
 (0)