Skip to content

Commit

Permalink
refactor: extract checkbox-group logic to reusable mixin (#7258)
Browse files Browse the repository at this point in the history
  • Loading branch information
web-padawan authored Mar 26, 2024
1 parent 17fa7b2 commit f38b8b3
Show file tree
Hide file tree
Showing 6 changed files with 342 additions and 281 deletions.
1 change: 1 addition & 0 deletions packages/checkbox-group/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.4.0-alpha18",
"@vaadin/checkbox": "24.4.0-alpha18",
Expand Down
36 changes: 36 additions & 0 deletions packages/checkbox-group/src/vaadin-checkbox-group-mixin.d.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Constructor<HTMLElement>>(
base: T,
): Constructor<CheckboxGroupMixinClass> &
Constructor<ControllerMixinClass> &
Constructor<DisabledMixinClass> &
Constructor<FieldMixinClass> &
Constructor<FocusMixinClass> &
Constructor<LabelMixinClass> &
Constructor<ValidateMixinClass> &
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[];
}
281 changes: 281 additions & 0 deletions packages/checkbox-group/src/vaadin-checkbox-group-mixin.js
Original file line number Diff line number Diff line change
@@ -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<!string>}
*/
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<!Checkbox>}
* @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<!Node>} nodes
* @return {!Array<!Checkbox>}
* @private
*/
__filterCheckboxes(nodes) {
return nodes.filter((node) => node.nodeType === Node.ELEMENT_NODE && node.localName === 'vaadin-checkbox');
}

/**
* @param {!Array<!Checkbox>} 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();
}
}
};
14 changes: 2 additions & 12 deletions packages/checkbox-group/src/vaadin-checkbox-group.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<K extends keyof CheckboxGroupEventMap>(
type: K,
listener: (this: CheckboxGroup, ev: CheckboxGroupEventMap[K]) => void,
Expand Down
Loading

0 comments on commit f38b8b3

Please sign in to comment.