From 702a8a88abea95d8f045ef07cb780041675d0b2e Mon Sep 17 00:00:00 2001 From: Tomi Virkki Date: Fri, 13 Dec 2024 15:28:54 +0200 Subject: [PATCH] refactor: extract form-layout logic into reusable mixins (#8342) --- packages/form-layout/package.json | 1 + .../src/vaadin-form-item-mixin.d.ts | 22 + .../form-layout/src/vaadin-form-item-mixin.js | 192 ++++++++ .../form-layout/src/vaadin-form-item.d.ts | 12 +- packages/form-layout/src/vaadin-form-item.js | 193 +------- .../src/vaadin-form-layout-mixin.d.ts | 68 +++ .../src/vaadin-form-layout-mixin.js | 429 +++++++++++++++++ .../form-layout/src/vaadin-form-layout.d.ts | 56 +-- .../form-layout/src/vaadin-form-layout.js | 430 +----------------- .../test/typings/form-layout.types.ts | 21 + 10 files changed, 752 insertions(+), 672 deletions(-) create mode 100644 packages/form-layout/src/vaadin-form-item-mixin.d.ts create mode 100644 packages/form-layout/src/vaadin-form-item-mixin.js create mode 100644 packages/form-layout/src/vaadin-form-layout-mixin.d.ts create mode 100644 packages/form-layout/src/vaadin-form-layout-mixin.js create mode 100644 packages/form-layout/test/typings/form-layout.types.ts diff --git a/packages/form-layout/package.json b/packages/form-layout/package.json index 06a2433c49..cb25eba931 100644 --- a/packages/form-layout/package.json +++ b/packages/form-layout/package.json @@ -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", diff --git a/packages/form-layout/src/vaadin-form-item-mixin.d.ts b/packages/form-layout/src/vaadin-form-item-mixin.d.ts new file mode 100644 index 0000000000..a12503f652 --- /dev/null +++ b/packages/form-layout/src/vaadin-form-item-mixin.d.ts @@ -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>(base: T): Constructor & 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; +} diff --git a/packages/form-layout/src/vaadin-form-item-mixin.js b/packages/form-layout/src/vaadin-form-item-mixin.js new file mode 100644 index 0000000000..bbb82f1529 --- /dev/null +++ b/packages/form-layout/src/vaadin-form-item-mixin.js @@ -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 is deprecated. +Please wrap fields with a 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); + } + }; diff --git a/packages/form-layout/src/vaadin-form-item.d.ts b/packages/form-layout/src/vaadin-form-item.d.ts index bb08b58045..f10e1a9851 100644 --- a/packages/form-layout/src/vaadin-form-item.d.ts +++ b/packages/form-layout/src/vaadin-form-item.d.ts @@ -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'; /** * `` is a Web Component providing labelled form item wrapper @@ -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 { diff --git a/packages/form-layout/src/vaadin-form-item.js b/packages/form-layout/src/vaadin-form-item.js index c4e94ed0ac..faf73caf67 100644 --- a/packages/form-layout/src/vaadin-form-item.js +++ b/packages/form-layout/src/vaadin-form-item.js @@ -5,9 +5,8 @@ */ import { html, PolymerElement } from '@polymer/polymer/polymer-element.js'; import { defineCustomElement } from '@vaadin/component-base/src/define.js'; -import { addValueToAttribute, removeValueFromAttribute } from '@vaadin/component-base/src/dom-utils.js'; -import { generateUniqueId } from '@vaadin/component-base/src/unique-id-utils.js'; import { registerStyles, ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; +import { FormItemMixin } from './vaadin-form-item-mixin.js'; import { formItemStyles } from './vaadin-form-layout-styles.js'; registerStyles('vaadin-form-item', formItemStyles, { moduleId: 'vaadin-form-item-styles' }); @@ -93,9 +92,14 @@ registerStyles('vaadin-form-item', formItemStyles, { moduleId: 'vaadin-form-item * * @customElement * @extends HTMLElement + * @mixes FormItemMixin * @mixes ThemableMixin */ -class FormItem extends ThemableMixin(PolymerElement) { +class FormItem extends FormItemMixin(ThemableMixin(PolymerElement)) { + static get is() { + return 'vaadin-form-item'; + } + static get template() { return html`
@@ -108,189 +112,6 @@ class FormItem extends ThemableMixin(PolymerElement) {
`; } - - static get is() { - return 'vaadin-form-item'; - } - - 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 is deprecated. -Please wrap fields with a 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); - } } defineCustomElement(FormItem); diff --git a/packages/form-layout/src/vaadin-form-layout-mixin.d.ts b/packages/form-layout/src/vaadin-form-layout-mixin.d.ts new file mode 100644 index 0000000000..2cde877ae5 --- /dev/null +++ b/packages/form-layout/src/vaadin-form-layout-mixin.d.ts @@ -0,0 +1,68 @@ +/** + * @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'; +import type { ResizeMixinClass } from '@vaadin/component-base/src/resize-mixin.js'; + +export type FormLayoutLabelsPosition = 'aside' | 'top'; + +export type FormLayoutResponsiveStep = { + minWidth?: string | 0; + columns: number; + labelsPosition?: FormLayoutLabelsPosition; +}; + +/** + * A mixin providing common form-layout functionality. + */ +export declare function FormLayoutMixin>( + base: T, +): Constructor & Constructor & T; + +export declare class FormLayoutMixinClass { + /** + * Allows specifying a responsive behavior with the number of columns + * and the label position depending on the layout width. + * + * Format: array of objects, each object defines one responsive step + * with `minWidth` CSS length, `columns` number, and optional + * `labelsPosition` string of `"aside"` or `"top"`. At least one item is required. + * + * #### Examples + * + * ```javascript + * formLayout.responsiveSteps = [{columns: 1}]; + * // The layout is always a single column, labels aside. + * ``` + * + * ```javascript + * formLayout.responsiveSteps = [ + * {minWidth: 0, columns: 1}, + * {minWidth: '40em', columns: 2} + * ]; + * // Sets two responsive steps: + * // 1. When the layout width is < 40em, one column, labels aside. + * // 2. Width >= 40em, two columns, labels aside. + * ``` + * + * ```javascript + * formLayout.responsiveSteps = [ + * {minWidth: 0, columns: 1, labelsPosition: 'top'}, + * {minWidth: '20em', columns: 1}, + * {minWidth: '40em', columns: 2} + * ]; + * // Default value. Three responsive steps: + * // 1. Width < 20em, one column, labels on top. + * // 2. 20em <= width < 40em, one column, labels aside. + * // 3. Width >= 40em, two columns, labels aside. + * ``` + */ + responsiveSteps: FormLayoutResponsiveStep[]; + + /** + * Update the layout. + */ + protected _updateLayout(): void; +} diff --git a/packages/form-layout/src/vaadin-form-layout-mixin.js b/packages/form-layout/src/vaadin-form-layout-mixin.js new file mode 100644 index 0000000000..abb0d329e3 --- /dev/null +++ b/packages/form-layout/src/vaadin-form-layout-mixin.js @@ -0,0 +1,429 @@ +/** + * @license + * Copyright (c) 2017 - 2024 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import { isElementHidden } from '@vaadin/a11y-base/src/focus-utils.js'; +import { ResizeMixin } from '@vaadin/component-base/src/resize-mixin.js'; + +/** + * @polymerMixin + * @mixes ResizeMixin + */ +export const FormLayoutMixin = (superClass) => + class extends ResizeMixin(superClass) { + static get properties() { + return { + /** + * @typedef FormLayoutResponsiveStep + * @type {object} + * @property {string} minWidth - The threshold value for this step in CSS length units. + * @property {number} columns - Number of columns. Only natural numbers are valid. + * @property {string} labelsPosition - Labels position option, valid values: `"aside"` (default), `"top"`. + */ + + /** + * Allows specifying a responsive behavior with the number of columns + * and the label position depending on the layout width. + * + * Format: array of objects, each object defines one responsive step + * with `minWidth` CSS length, `columns` number, and optional + * `labelsPosition` string of `"aside"` or `"top"`. At least one item is required. + * + * #### Examples + * + * ```javascript + * formLayout.responsiveSteps = [{columns: 1}]; + * // The layout is always a single column, labels aside. + * ``` + * + * ```javascript + * formLayout.responsiveSteps = [ + * {minWidth: 0, columns: 1}, + * {minWidth: '40em', columns: 2} + * ]; + * // Sets two responsive steps: + * // 1. When the layout width is < 40em, one column, labels aside. + * // 2. Width >= 40em, two columns, labels aside. + * ``` + * + * ```javascript + * formLayout.responsiveSteps = [ + * {minWidth: 0, columns: 1, labelsPosition: 'top'}, + * {minWidth: '20em', columns: 1}, + * {minWidth: '40em', columns: 2} + * ]; + * // Default value. Three responsive steps: + * // 1. Width < 20em, one column, labels on top. + * // 2. 20em <= width < 40em, one column, labels aside. + * // 3. Width >= 40em, two columns, labels aside. + * ``` + * + * @type {!Array} + */ + responsiveSteps: { + type: Array, + value() { + return [ + { minWidth: 0, columns: 1, labelsPosition: 'top' }, + { minWidth: '20em', columns: 1 }, + { minWidth: '40em', columns: 2 }, + ]; + }, + observer: '_responsiveStepsChanged', + }, + + /** + * Current number of columns in the layout + * @private + */ + _columnCount: { + type: Number, + }, + + /** + * Indicates that labels are on top + * @private + */ + _labelsOnTop: { + type: Boolean, + }, + + /** @private */ + __isVisible: { + type: Boolean, + }, + }; + } + + static get observers() { + return ['_invokeUpdateLayout(_columnCount, _labelsOnTop)']; + } + + /** @protected */ + ready() { + // Here we create and attach a style element that we use for validating + // CSS values in `responsiveSteps`. We can't add this to the `