From f38b8b3147510b80857d2b49ca30038f1033a059 Mon Sep 17 00:00:00 2001 From: Serhii Kulykov Date: Tue, 26 Mar 2024 09:57:05 +0200 Subject: [PATCH] refactor: extract checkbox-group logic to reusable mixin (#7258) --- packages/checkbox-group/package.json | 1 + .../src/vaadin-checkbox-group-mixin.d.ts | 36 +++ .../src/vaadin-checkbox-group-mixin.js | 281 ++++++++++++++++++ .../src/vaadin-checkbox-group.d.ts | 14 +- .../src/vaadin-checkbox-group.js | 273 +---------------- .../test/typings/checkbox-group.types.ts | 18 ++ 6 files changed, 342 insertions(+), 281 deletions(-) create mode 100644 packages/checkbox-group/src/vaadin-checkbox-group-mixin.d.ts create mode 100644 packages/checkbox-group/src/vaadin-checkbox-group-mixin.js diff --git a/packages/checkbox-group/package.json b/packages/checkbox-group/package.json index 0d5c6f78484..62483b037d2 100644 --- a/packages/checkbox-group/package.json +++ b/packages/checkbox-group/package.json @@ -35,6 +35,7 @@ "polymer" ], "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", "@polymer/polymer": "^3.0.0", "@vaadin/a11y-base": "24.4.0-alpha18", "@vaadin/checkbox": "24.4.0-alpha18", diff --git a/packages/checkbox-group/src/vaadin-checkbox-group-mixin.d.ts b/packages/checkbox-group/src/vaadin-checkbox-group-mixin.d.ts new file mode 100644 index 00000000000..4937a7d0022 --- /dev/null +++ b/packages/checkbox-group/src/vaadin-checkbox-group-mixin.d.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright (c) 2018 - 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 { DisabledMixinClass } from '@vaadin/a11y-base/src/disabled-mixin.js'; +import type { FocusMixinClass } from '@vaadin/a11y-base/src/focus-mixin.js'; +import type { ControllerMixinClass } from '@vaadin/component-base/src/controller-mixin.js'; +import type { FieldMixinClass } from '@vaadin/field-base/src/field-mixin.js'; +import type { LabelMixinClass } from '@vaadin/field-base/src/label-mixin.js'; +import type { ValidateMixinClass } from '@vaadin/field-base/src/validate-mixin.js'; + +/** + * A mixin providing common checkbox-group functionality. + */ +export declare function CheckboxGroupMixin>( + base: T, +): Constructor & + Constructor & + Constructor & + Constructor & + Constructor & + Constructor & + Constructor & + T; + +export declare class CheckboxGroupMixinClass { + /** + * An array containing values of the currently checked checkboxes. + * + * The array is immutable so toggling checkboxes always results in + * creating a new array. + */ + value: string[]; +} diff --git a/packages/checkbox-group/src/vaadin-checkbox-group-mixin.js b/packages/checkbox-group/src/vaadin-checkbox-group-mixin.js new file mode 100644 index 00000000000..f61b78b606c --- /dev/null +++ b/packages/checkbox-group/src/vaadin-checkbox-group-mixin.js @@ -0,0 +1,281 @@ +/** + * @license + * Copyright (c) 2018 - 2024 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ +import { DisabledMixin } from '@vaadin/a11y-base/src/disabled-mixin.js'; +import { FocusMixin } from '@vaadin/a11y-base/src/focus-mixin.js'; +import { SlotObserver } from '@vaadin/component-base/src/slot-observer.js'; +import { TooltipController } from '@vaadin/component-base/src/tooltip-controller.js'; +import { FieldMixin } from '@vaadin/field-base/src/field-mixin.js'; + +/** + * A mixin providing common checkbox-group functionality. + * + * @polymerMixin + * @mixes DisabledMixin + * @mixes FieldMixin + * @mixes FocusMixin + * @mixes KeyboardMixin + */ +export const CheckboxGroupMixin = (superclass) => + class CheckboxGroupMixinClass extends FieldMixin(FocusMixin(DisabledMixin(superclass))) { + static get properties() { + return { + /** + * An array containing values of the currently checked checkboxes. + * + * The array is immutable so toggling checkboxes always results in + * creating a new array. + * + * @type {!Array} + */ + value: { + type: Array, + value: () => [], + notify: true, + observer: '__valueChanged', + }, + }; + } + + constructor() { + super(); + + this.__registerCheckbox = this.__registerCheckbox.bind(this); + this.__unregisterCheckbox = this.__unregisterCheckbox.bind(this); + this.__onCheckboxCheckedChanged = this.__onCheckboxCheckedChanged.bind(this); + + this._tooltipController = new TooltipController(this); + this._tooltipController.addEventListener('tooltip-changed', (event) => { + const tooltip = event.detail.node; + if (tooltip && tooltip.isConnected) { + // Tooltip element has been added to the DOM + const inputs = this.__checkboxes.map((checkbox) => checkbox.inputElement); + this._tooltipController.setAriaTarget(inputs); + } else { + // Tooltip element is no longer connected + this._tooltipController.setAriaTarget([]); + } + }); + } + + /** + * A collection of the checkboxes. + * + * @return {!Array} + * @private + */ + get __checkboxes() { + return this.__filterCheckboxes([...this.children]); + } + + /** @protected */ + ready() { + super.ready(); + + this.ariaTarget = this; + + // See https://github.com/vaadin/vaadin-web-components/issues/94 + this.setAttribute('role', 'group'); + + const slot = this.shadowRoot.querySelector('slot:not([name])'); + this._observer = new SlotObserver(slot, ({ addedNodes, removedNodes }) => { + const addedCheckboxes = this.__filterCheckboxes(addedNodes); + const removedCheckboxes = this.__filterCheckboxes(removedNodes); + + addedCheckboxes.forEach(this.__registerCheckbox); + removedCheckboxes.forEach(this.__unregisterCheckbox); + + const inputs = this.__checkboxes.map((checkbox) => checkbox.inputElement); + this._tooltipController.setAriaTarget(inputs); + + this.__warnOfCheckboxesWithoutValue(addedCheckboxes); + }); + + this.addController(this._tooltipController); + } + + /** + * Override method inherited from `ValidateMixin` + * to validate the value array. + * + * @override + * @return {boolean} + */ + checkValidity() { + return !this.required || this.value.length > 0; + } + + /** + * @param {!Array} nodes + * @return {!Array} + * @private + */ + __filterCheckboxes(nodes) { + return nodes.filter((node) => node.nodeType === Node.ELEMENT_NODE && node.localName === 'vaadin-checkbox'); + } + + /** + * @param {!Array} checkboxes + * @private + */ + __warnOfCheckboxesWithoutValue(checkboxes) { + const hasCheckboxesWithoutValue = checkboxes.some((checkbox) => { + const { value } = checkbox; + + return !checkbox.hasAttribute('value') && (!value || value === 'on'); + }); + + if (hasCheckboxesWithoutValue) { + console.warn('Please provide the value attribute to all the checkboxes inside the checkbox group.'); + } + } + + /** + * Registers the checkbox after adding it to the group. + * + * @param {!Checkbox} checkbox + * @private + */ + __registerCheckbox(checkbox) { + checkbox.addEventListener('checked-changed', this.__onCheckboxCheckedChanged); + + if (this.disabled) { + checkbox.disabled = true; + } + + if (checkbox.checked) { + this.__addCheckboxToValue(checkbox.value); + } else if (this.value.includes(checkbox.value)) { + checkbox.checked = true; + } + } + + /** + * Unregisters the checkbox before removing it from the group. + * + * @param {!Checkbox} checkbox + * @private + */ + __unregisterCheckbox(checkbox) { + checkbox.removeEventListener('checked-changed', this.__onCheckboxCheckedChanged); + + if (checkbox.checked) { + this.__removeCheckboxFromValue(checkbox.value); + } + } + + /** + * Override method inherited from `DisabledMixin` + * to propagate the `disabled` property to the checkboxes. + * + * @param {boolean} newValue + * @param {boolean} oldValue + * @override + * @protected + */ + _disabledChanged(newValue, oldValue) { + super._disabledChanged(newValue, oldValue); + + // Prevent updating the `disabled` property for the checkboxes at initialization. + // Otherwise, the checkboxes may end up enabled regardless the `disabled` attribute + // intentionally added by the user on some of them. + if (!newValue && oldValue === undefined) { + return; + } + + if (oldValue !== newValue) { + this.__checkboxes.forEach((checkbox) => { + checkbox.disabled = newValue; + }); + } + } + + /** + * @param {string} value + * @private + */ + __addCheckboxToValue(value) { + if (!this.value.includes(value)) { + this.value = [...this.value, value]; + } + } + + /** + * @param {string} value + * @private + */ + __removeCheckboxFromValue(value) { + if (this.value.includes(value)) { + this.value = this.value.filter((v) => v !== value); + } + } + + /** + * @param {!CustomEvent} event + * @private + */ + __onCheckboxCheckedChanged(event) { + const checkbox = event.target; + + if (checkbox.checked) { + this.__addCheckboxToValue(checkbox.value); + } else { + this.__removeCheckboxFromValue(checkbox.value); + } + } + + /** + * @param {string | null | undefined} value + * @param {string | null | undefined} oldValue + * @private + */ + __valueChanged(value, oldValue) { + // Setting initial value to empty array, skip validation + if (value.length === 0 && oldValue === undefined) { + return; + } + + this.toggleAttribute('has-value', value.length > 0); + + this.__checkboxes.forEach((checkbox) => { + checkbox.checked = value.includes(checkbox.value); + }); + + if (oldValue !== undefined) { + this.validate(); + } + } + + /** + * Override method inherited from `FocusMixin` + * to prevent removing the `focused` attribute + * when focus moves between checkboxes inside the group. + * + * @param {!FocusEvent} event + * @return {boolean} + * @protected + */ + _shouldRemoveFocus(event) { + return !this.contains(event.relatedTarget); + } + + /** + * Override method inherited from `FocusMixin` + * to run validation when the group loses focus. + * + * @param {boolean} focused + * @override + * @protected + */ + _setFocused(focused) { + super._setFocused(focused); + + // Do not validate when focusout is caused by document + // losing focus, which happens on browser tab switch. + if (!focused && document.hasFocus()) { + this.validate(); + } + } + }; diff --git a/packages/checkbox-group/src/vaadin-checkbox-group.d.ts b/packages/checkbox-group/src/vaadin-checkbox-group.d.ts index bbf8411bf89..af44f5da245 100644 --- a/packages/checkbox-group/src/vaadin-checkbox-group.d.ts +++ b/packages/checkbox-group/src/vaadin-checkbox-group.d.ts @@ -3,11 +3,9 @@ * Copyright (c) 2018 - 2024 Vaadin Ltd. * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ -import { DisabledMixin } from '@vaadin/a11y-base/src/disabled-mixin.js'; -import { FocusMixin } from '@vaadin/a11y-base/src/focus-mixin.js'; import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; -import { FieldMixin } from '@vaadin/field-base/src/field-mixin.js'; import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; +import { CheckboxGroupMixin } from './vaadin-checkbox-group-mixin.js'; /** * Fired when the `invalid` property changes. @@ -76,15 +74,7 @@ export interface CheckboxGroupEventMap extends HTMLElementEventMap, CheckboxGrou * @fires {CustomEvent} value-changed - Fired when the `value` property changes. * @fires {CustomEvent} validated - Fired whenever the field is validated. */ -declare class CheckboxGroup extends FieldMixin(FocusMixin(DisabledMixin(ElementMixin(ThemableMixin(HTMLElement))))) { - /** - * An array containing values of the currently checked checkboxes. - * - * The array is immutable so toggling checkboxes always results in - * creating a new array. - */ - value: string[]; - +declare class CheckboxGroup extends CheckboxGroupMixin(ElementMixin(ThemableMixin(HTMLElement))) { addEventListener( type: K, listener: (this: CheckboxGroup, ev: CheckboxGroupEventMap[K]) => void, diff --git a/packages/checkbox-group/src/vaadin-checkbox-group.js b/packages/checkbox-group/src/vaadin-checkbox-group.js index f5e6f4f76cb..935cd8fc59d 100644 --- a/packages/checkbox-group/src/vaadin-checkbox-group.js +++ b/packages/checkbox-group/src/vaadin-checkbox-group.js @@ -3,16 +3,12 @@ * Copyright (c) 2018 - 2024 Vaadin Ltd. * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ +import '@vaadin/checkbox/src/vaadin-checkbox.js'; import { html, PolymerElement } from '@polymer/polymer/polymer-element.js'; -import { DisabledMixin } from '@vaadin/a11y-base/src/disabled-mixin.js'; -import { FocusMixin } from '@vaadin/a11y-base/src/focus-mixin.js'; -import { Checkbox } from '@vaadin/checkbox/src/vaadin-checkbox.js'; import { defineCustomElement } from '@vaadin/component-base/src/define.js'; import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; -import { SlotObserver } from '@vaadin/component-base/src/slot-observer.js'; -import { TooltipController } from '@vaadin/component-base/src/tooltip-controller.js'; -import { FieldMixin } from '@vaadin/field-base/src/field-mixin.js'; import { registerStyles, ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; +import { CheckboxGroupMixin } from './vaadin-checkbox-group-mixin.js'; import { checkboxGroupStyles } from './vaadin-checkbox-group-styles.js'; registerStyles('vaadin-checkbox-group', checkboxGroupStyles, { moduleId: 'vaadin-checkbox-group-styles' }); @@ -62,12 +58,10 @@ registerStyles('vaadin-checkbox-group', checkboxGroupStyles, { moduleId: 'vaadin * @customElement * @extends HTMLElement * @mixes ThemableMixin - * @mixes DisabledMixin * @mixes ElementMixin - * @mixes FocusMixin - * @mixes FieldMixin + * @mixes CheckboxGroupMixin */ -class CheckboxGroup extends FieldMixin(FocusMixin(DisabledMixin(ElementMixin(ThemableMixin(PolymerElement))))) { +class CheckboxGroup extends CheckboxGroupMixin(ElementMixin(ThemableMixin(PolymerElement))) { static get is() { return 'vaadin-checkbox-group'; } @@ -96,265 +90,6 @@ class CheckboxGroup extends FieldMixin(FocusMixin(DisabledMixin(ElementMixin(The `; } - - static get properties() { - return { - /** - * An array containing values of the currently checked checkboxes. - * - * The array is immutable so toggling checkboxes always results in - * creating a new array. - * - * @type {!Array} - */ - value: { - type: Array, - value: () => [], - notify: true, - observer: '__valueChanged', - }, - }; - } - - constructor() { - super(); - - this.__registerCheckbox = this.__registerCheckbox.bind(this); - this.__unregisterCheckbox = this.__unregisterCheckbox.bind(this); - this.__onCheckboxCheckedChanged = this.__onCheckboxCheckedChanged.bind(this); - - this._tooltipController = new TooltipController(this); - this._tooltipController.addEventListener('tooltip-changed', (event) => { - const tooltip = event.detail.node; - if (tooltip && tooltip.isConnected) { - // Tooltip element has been added to the DOM - const inputs = this.__checkboxes.map((checkbox) => checkbox.inputElement); - this._tooltipController.setAriaTarget(inputs); - } else { - // Tooltip element is no longer connected - this._tooltipController.setAriaTarget([]); - } - }); - } - - /** - * A collection of the checkboxes. - * - * @return {!Array} - * @private - */ - get __checkboxes() { - return this.__filterCheckboxes([...this.children]); - } - - /** @protected */ - ready() { - super.ready(); - - this.ariaTarget = this; - - // See https://github.com/vaadin/vaadin-web-components/issues/94 - this.setAttribute('role', 'group'); - - const slot = this.shadowRoot.querySelector('slot:not([name])'); - this._observer = new SlotObserver(slot, ({ addedNodes, removedNodes }) => { - const addedCheckboxes = this.__filterCheckboxes(addedNodes); - const removedCheckboxes = this.__filterCheckboxes(removedNodes); - - addedCheckboxes.forEach(this.__registerCheckbox); - removedCheckboxes.forEach(this.__unregisterCheckbox); - - const inputs = this.__checkboxes.map((checkbox) => checkbox.inputElement); - this._tooltipController.setAriaTarget(inputs); - - this.__warnOfCheckboxesWithoutValue(addedCheckboxes); - }); - - this.addController(this._tooltipController); - } - - /** - * Override method inherited from `ValidateMixin` - * to validate the value array. - * - * @override - * @return {boolean} - */ - checkValidity() { - return !this.required || this.value.length > 0; - } - - /** - * @param {!Array} nodes - * @return {!Array} - * @private - */ - __filterCheckboxes(nodes) { - return nodes.filter((child) => child instanceof Checkbox); - } - - /** - * @param {!Array} checkboxes - * @private - */ - __warnOfCheckboxesWithoutValue(checkboxes) { - const hasCheckboxesWithoutValue = checkboxes.some((checkbox) => { - const { value } = checkbox; - - return !checkbox.hasAttribute('value') && (!value || value === 'on'); - }); - - if (hasCheckboxesWithoutValue) { - console.warn('Please provide the value attribute to all the checkboxes inside the checkbox group.'); - } - } - - /** - * Registers the checkbox after adding it to the group. - * - * @param {!Checkbox} checkbox - * @private - */ - __registerCheckbox(checkbox) { - checkbox.addEventListener('checked-changed', this.__onCheckboxCheckedChanged); - - if (this.disabled) { - checkbox.disabled = true; - } - - if (checkbox.checked) { - this.__addCheckboxToValue(checkbox.value); - } else if (this.value.includes(checkbox.value)) { - checkbox.checked = true; - } - } - - /** - * Unregisters the checkbox before removing it from the group. - * - * @param {!Checkbox} checkbox - * @private - */ - __unregisterCheckbox(checkbox) { - checkbox.removeEventListener('checked-changed', this.__onCheckboxCheckedChanged); - - if (checkbox.checked) { - this.__removeCheckboxFromValue(checkbox.value); - } - } - - /** - * Override method inherited from `DisabledMixin` - * to propagate the `disabled` property to the checkboxes. - * - * @param {boolean} newValue - * @param {boolean} oldValue - * @override - * @protected - */ - _disabledChanged(newValue, oldValue) { - super._disabledChanged(newValue, oldValue); - - // Prevent updating the `disabled` property for the checkboxes at initialization. - // Otherwise, the checkboxes may end up enabled regardless the `disabled` attribute - // intentionally added by the user on some of them. - if (!newValue && oldValue === undefined) { - return; - } - - if (oldValue !== newValue) { - this.__checkboxes.forEach((checkbox) => { - checkbox.disabled = newValue; - }); - } - } - - /** - * @param {string} value - * @private - */ - __addCheckboxToValue(value) { - if (!this.value.includes(value)) { - this.value = [...this.value, value]; - } - } - - /** - * @param {string} value - * @private - */ - __removeCheckboxFromValue(value) { - if (this.value.includes(value)) { - this.value = this.value.filter((v) => v !== value); - } - } - - /** - * @param {!CustomEvent} event - * @private - */ - __onCheckboxCheckedChanged(event) { - const checkbox = event.target; - - if (checkbox.checked) { - this.__addCheckboxToValue(checkbox.value); - } else { - this.__removeCheckboxFromValue(checkbox.value); - } - } - - /** - * @param {string | null | undefined} value - * @param {string | null | undefined} oldValue - * @private - */ - __valueChanged(value, oldValue) { - // Setting initial value to empty array, skip validation - if (value.length === 0 && oldValue === undefined) { - return; - } - - this.toggleAttribute('has-value', value.length > 0); - - this.__checkboxes.forEach((checkbox) => { - checkbox.checked = value.includes(checkbox.value); - }); - - if (oldValue !== undefined) { - this.validate(); - } - } - - /** - * Override method inherited from `FocusMixin` - * to prevent removing the `focused` attribute - * when focus moves between checkboxes inside the group. - * - * @param {!FocusEvent} event - * @return {boolean} - * @protected - */ - _shouldRemoveFocus(event) { - return !this.contains(event.relatedTarget); - } - - /** - * Override method inherited from `FocusMixin` - * to run validation when the group loses focus. - * - * @param {boolean} focused - * @override - * @protected - */ - _setFocused(focused) { - super._setFocused(focused); - - // Do not validate when focusout is caused by document - // losing focus, which happens on browser tab switch. - if (!focused && document.hasFocus()) { - this.validate(); - } - } } defineCustomElement(CheckboxGroup); diff --git a/packages/checkbox-group/test/typings/checkbox-group.types.ts b/packages/checkbox-group/test/typings/checkbox-group.types.ts index 8e8d58cad74..0a96e8b2a90 100644 --- a/packages/checkbox-group/test/typings/checkbox-group.types.ts +++ b/packages/checkbox-group/test/typings/checkbox-group.types.ts @@ -1,4 +1,12 @@ import '../../vaadin-checkbox-group.js'; +import type { DisabledMixinClass } from '@vaadin/a11y-base/src/disabled-mixin.js'; +import type { FocusMixinClass } from '@vaadin/a11y-base/src/focus-mixin.js'; +import type { ControllerMixinClass } from '@vaadin/component-base/src/controller-mixin.js'; +import type { ElementMixinClass } from '@vaadin/component-base/src/element-mixin.js'; +import type { FieldMixinClass } from '@vaadin/field-base/src/field-mixin.js'; +import type { LabelMixinClass } from '@vaadin/field-base/src/label-mixin.js'; +import type { ThemableMixinClass } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; +import type { CheckboxGroupMixinClass } from '../../src/vaadin-checkbox-group-mixin.js'; import type { CheckboxGroupInvalidChangedEvent, CheckboxGroupValidatedEvent, @@ -23,3 +31,13 @@ group.addEventListener('validated', (event) => { assertType(event); assertType(event.detail.valid); }); + +// Mixins +assertType(group); +assertType(group); +assertType(group); +assertType(group); +assertType(group); +assertType(group); +assertType(group); +assertType(group);