Skip to content

Commit

Permalink
refactor: extract form-layout logic into reusable mixins (#8342)
Browse files Browse the repository at this point in the history
  • Loading branch information
tomivirkki authored Dec 13, 2024
1 parent cfb8cb6 commit 702a8a8
Show file tree
Hide file tree
Showing 10 changed files with 752 additions and 672 deletions.
1 change: 1 addition & 0 deletions packages/form-layout/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"polymer"
],
"dependencies": {
"@open-wc/dedupe-mixin": "^1.3.0",
"@polymer/polymer": "^3.0.0",
"@vaadin/a11y-base": "24.7.0-alpha1",
"@vaadin/component-base": "24.7.0-alpha1",
Expand Down
22 changes: 22 additions & 0 deletions packages/form-layout/src/vaadin-form-item-mixin.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* @license
* Copyright (c) 2017 - 2024 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import type { Constructor } from '@open-wc/dedupe-mixin';

/**
* A mixin providing common form-item functionality.
*/
export declare function FormItemMixin<T extends Constructor<HTMLElement>>(base: T): Constructor<FormItemMixinClass> & T;

export declare class FormItemMixinClass {
/**
* Returns a target element to add ARIA attributes to for a field.
*
* - For Vaadin field components, the method returns an element
* obtained through the `ariaTarget` property defined in `FieldMixin`.
* - In other cases, the method returns the field element itself.
*/
protected _getFieldAriaTarget(field: HTMLElement): HTMLElement;
}
192 changes: 192 additions & 0 deletions packages/form-layout/src/vaadin-form-item-mixin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* @license
* Copyright (c) 2017 - 2024 Vaadin Ltd.
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { addValueToAttribute, removeValueFromAttribute } from '@vaadin/component-base/src/dom-utils.js';
import { generateUniqueId } from '@vaadin/component-base/src/unique-id-utils.js';

/**
* @polymerMixin
*/
export const FormItemMixin = (superClass) =>
class extends superClass {
constructor() {
super();
this.__updateInvalidState = this.__updateInvalidState.bind(this);

/**
* An observer for a field node to reflect its `required` and `invalid` attributes to the component.
*
* @type {MutationObserver}
* @private
*/
this.__fieldNodeObserver = new MutationObserver(() => this.__updateRequiredState(this.__fieldNode.required));

/**
* The first label node in the label slot.
*
* @type {HTMLElement | null}
* @private
*/
this.__labelNode = null;

/**
* The first field node in the content slot.
*
* An element is considered a field when it has the `checkValidity` or `validate` method.
*
* @type {HTMLElement | null}
* @private
*/
this.__fieldNode = null;
}

/**
* Returns a target element to add ARIA attributes to for a field.
*
* - For Vaadin field components, the method returns an element
* obtained through the `ariaTarget` property defined in `FieldMixin`.
* - In other cases, the method returns the field element itself.
*
* @param {HTMLElement} field
* @protected
*/
_getFieldAriaTarget(field) {
return field.ariaTarget || field;
}

/**
* Links the label to a field by adding the label id to
* the `aria-labelledby` attribute of the field's ARIA target element.
*
* @param {HTMLElement} field
* @private
*/
__linkLabelToField(field) {
addValueToAttribute(this._getFieldAriaTarget(field), 'aria-labelledby', this.__labelId);
}

/**
* Unlinks the label from a field by removing the label id from
* the `aria-labelledby` attribute of the field's ARIA target element.
*
* @param {HTMLElement} field
* @private
*/
__unlinkLabelFromField(field) {
removeValueFromAttribute(this._getFieldAriaTarget(field), 'aria-labelledby', this.__labelId);
}

/** @private */
__onLabelClick() {
const fieldNode = this.__fieldNode;
if (fieldNode) {
fieldNode.focus();
fieldNode.click();
}
}

/** @private */
__getValidateFunction(field) {
return field.validate || field.checkValidity;
}

/**
* A `slotchange` event handler for the label slot.
*
* - Ensures the label id is only assigned to the first label node.
* - Ensures the label node is linked to the first field node via the `aria-labelledby` attribute
* if both nodes are provided, and unlinked otherwise.
*
* @private
*/
__onLabelSlotChange() {
if (this.__labelNode) {
this.__labelNode = null;

if (this.__fieldNode) {
this.__unlinkLabelFromField(this.__fieldNode);
}
}

const newLabelNode = this.$.labelSlot.assignedElements()[0];
if (newLabelNode) {
this.__labelNode = newLabelNode;

if (this.__labelNode.id) {
// The new label node already has an id. Let's use it.
this.__labelId = this.__labelNode.id;
} else {
// The new label node doesn't have an id yet. Generate a unique one.
this.__labelId = `label-${this.localName}-${generateUniqueId()}`;
this.__labelNode.id = this.__labelId;
}

if (this.__fieldNode) {
this.__linkLabelToField(this.__fieldNode);
}
}
}

/**
* A `slotchange` event handler for the content slot.
*
* - Ensures the label node is only linked to the first field node via the `aria-labelledby` attribute.
* - Sets up an observer for the `required` attribute changes on the first field
* to reflect the attribute on the component. Ensures the observer is disconnected from the field
* as soon as it is removed or replaced by another one.
*
* @private
*/
__onContentSlotChange() {
if (this.__fieldNode) {
// Discard the old field
this.__unlinkLabelFromField(this.__fieldNode);
this.__updateRequiredState(false);
this.__fieldNodeObserver.disconnect();
this.__fieldNode = null;
}

const fieldNodes = this.$.contentSlot.assignedElements();
if (fieldNodes.length > 1) {
console.warn(
`WARNING: Since Vaadin 23, placing multiple fields directly to a <vaadin-form-item> is deprecated.
Please wrap fields with a <vaadin-custom-field> instead.`,
);
}

const newFieldNode = fieldNodes.find((field) => {
return !!this.__getValidateFunction(field);
});
if (newFieldNode) {
this.__fieldNode = newFieldNode;
this.__updateRequiredState(this.__fieldNode.required);
this.__fieldNodeObserver.observe(this.__fieldNode, { attributes: true, attributeFilter: ['required'] });

if (this.__labelNode) {
this.__linkLabelToField(this.__fieldNode);
}
}
}

/** @private */
__updateRequiredState(required) {
if (required) {
this.setAttribute('required', '');
this.__fieldNode.addEventListener('blur', this.__updateInvalidState);
this.__fieldNode.addEventListener('change', this.__updateInvalidState);
} else {
this.removeAttribute('invalid');
this.removeAttribute('required');
this.__fieldNode.removeEventListener('blur', this.__updateInvalidState);
this.__fieldNode.removeEventListener('change', this.__updateInvalidState);
}
}

/** @private */
__updateInvalidState() {
const isValid = this.__getValidateFunction(this.__fieldNode).call(this.__fieldNode);
this.toggleAttribute('invalid', isValid === false);
}
};
12 changes: 2 additions & 10 deletions packages/form-layout/src/vaadin-form-item.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* This program is available under Apache License Version 2.0, available at https://vaadin.com/license/
*/
import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js';
import { FormItemMixin } from './vaadin-form-item-mixin.js';

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

declare global {
interface HTMLElementTagNameMap {
Expand Down
Loading

0 comments on commit 702a8a8

Please sign in to comment.