diff --git a/packages/checkbox-group/src/vaadin-checkbox-group.js b/packages/checkbox-group/src/vaadin-checkbox-group.js index 872eadbc1b..5be2d42e49 100644 --- a/packages/checkbox-group/src/vaadin-checkbox-group.js +++ b/packages/checkbox-group/src/vaadin-checkbox-group.js @@ -161,15 +161,6 @@ class CheckboxGroup extends FieldMixin(FocusMixin(DisabledMixin(DirMixin(Themabl }); } - /** - * @return {string} - * @override - * @protected - */ - get _ariaAttr() { - return 'aria-labelledby'; - } - /** * Override method inherited from `ValidateMixin` * to validate the value array. diff --git a/packages/checkbox-group/test/checkbox-group.test.js b/packages/checkbox-group/test/checkbox-group.test.js index 2cbae70fc4..4904b8e622 100644 --- a/packages/checkbox-group/test/checkbox-group.test.js +++ b/packages/checkbox-group/test/checkbox-group.test.js @@ -448,7 +448,7 @@ describe('vaadin-checkbox-group', () => { let error, helper, label; beforeEach(() => { - group = fixtureSync(''); + group = fixtureSync(''); error = group.querySelector('[slot=error-message]'); helper = group.querySelector('[slot=helper]'); label = group.querySelector('[slot=label]'); diff --git a/packages/checkbox/src/vaadin-checkbox.js b/packages/checkbox/src/vaadin-checkbox.js index 374c75444b..8ff1901d4e 100644 --- a/packages/checkbox/src/vaadin-checkbox.js +++ b/packages/checkbox/src/vaadin-checkbox.js @@ -7,10 +7,10 @@ import { html, PolymerElement } from '@polymer/polymer/polymer-element.js'; import { ActiveMixin } from '@vaadin/component-base/src/active-mixin.js'; import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js'; import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; -import { AriaLabelController } from '@vaadin/field-base/src/aria-label-controller.js'; import { CheckedMixin } from '@vaadin/field-base/src/checked-mixin.js'; import { DelegateFocusMixin } from '@vaadin/field-base/src/delegate-focus-mixin.js'; import { InputController } from '@vaadin/field-base/src/input-controller.js'; +import { LabelledInputController } from '@vaadin/field-base/src/labelled-input-controller.js'; import { SlotLabelMixin } from '@vaadin/field-base/src/slot-label-mixin.js'; import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; @@ -176,7 +176,7 @@ class Checkbox extends SlotLabelMixin( this.stateTarget = input; }) ); - this.addController(new AriaLabelController(this, this.inputElement, this._labelNode)); + this.addController(new LabelledInputController(this.inputElement, this._labelNode)); } /** diff --git a/packages/combo-box/src/vaadin-combo-box.js b/packages/combo-box/src/vaadin-combo-box.js index c69c192e53..00a7f4b579 100644 --- a/packages/combo-box/src/vaadin-combo-box.js +++ b/packages/combo-box/src/vaadin-combo-box.js @@ -6,11 +6,10 @@ import '@vaadin/input-container/src/vaadin-input-container.js'; import './vaadin-combo-box-dropdown.js'; import { html, PolymerElement } from '@polymer/polymer/polymer-element.js'; -import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js'; import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; -import { AriaLabelController } from '@vaadin/field-base/src/aria-label-controller.js'; import { InputControlMixin } from '@vaadin/field-base/src/input-control-mixin.js'; import { InputController } from '@vaadin/field-base/src/input-controller.js'; +import { LabelledInputController } from '@vaadin/field-base/src/labelled-input-controller.js'; import { PatternMixin } from '@vaadin/field-base/src/pattern-mixin.js'; import { inputFieldShared } from '@vaadin/field-base/src/styles/input-field-shared-styles.js'; import { registerStyles, ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; @@ -147,7 +146,6 @@ registerStyles('vaadin-combo-box', inputFieldShared, { moduleId: 'vaadin-combo-b * @fires {CustomEvent} value-changed - Fired when the `value` property changes. * * @extends HTMLElement - * @mixes ControllerMixin * @mixes ElementMixin * @mixes ThemableMixin * @mixes InputControlMixin @@ -156,7 +154,7 @@ registerStyles('vaadin-combo-box', inputFieldShared, { moduleId: 'vaadin-combo-b * @mixes ComboBoxMixin */ class ComboBox extends ComboBoxDataProviderMixin( - ComboBoxMixin(PatternMixin(InputControlMixin(ThemableMixin(ElementMixin(ControllerMixin(PolymerElement)))))) + ComboBoxMixin(PatternMixin(InputControlMixin(ThemableMixin(ElementMixin(PolymerElement))))) ) { static get is() { return 'vaadin-combo-box'; @@ -244,7 +242,7 @@ class ComboBox extends ComboBoxDataProviderMixin( this.ariaTarget = input; }) ); - this.addController(new AriaLabelController(this, this.inputElement, this._labelNode)); + this.addController(new LabelledInputController(this.inputElement, this._labelNode)); this._positionTarget = this.shadowRoot.querySelector('[part="input-field"]'); this._toggleElement = this.$.toggleButton; } diff --git a/packages/custom-field/src/vaadin-custom-field.js b/packages/custom-field/src/vaadin-custom-field.js index a49a062cfc..76ff8e9902 100644 --- a/packages/custom-field/src/vaadin-custom-field.js +++ b/packages/custom-field/src/vaadin-custom-field.js @@ -189,14 +189,6 @@ class CustomField extends FieldMixin(FocusMixin(ThemableMixin(ElementMixin(Polym }; } - /** - * Attribute used by `FieldMixin` to set accessible name. - * @protected - */ - get _ariaAttr() { - return 'aria-labelledby'; - } - /** @protected */ connectedCallback() { super.connectedCallback(); diff --git a/packages/date-picker/src/vaadin-date-picker.d.ts b/packages/date-picker/src/vaadin-date-picker.d.ts index 0efcc521eb..7af94c751d 100644 --- a/packages/date-picker/src/vaadin-date-picker.d.ts +++ b/packages/date-picker/src/vaadin-date-picker.d.ts @@ -3,7 +3,6 @@ * Copyright (c) 2021 Vaadin Ltd. * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ -import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js'; import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; import { InputControlMixin } from '@vaadin/field-base/src/input-control-mixin.js'; import { ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; @@ -126,9 +125,7 @@ export interface DatePickerEventMap extends HTMLElementEventMap, DatePickerCusto * @fires {CustomEvent} opened-changed - Fired when the `opened` property changes. * @fires {CustomEvent} value-changed - Fired when the `value` property changes. */ -declare class DatePicker extends DatePickerMixin( - InputControlMixin(ThemableMixin(ElementMixin(ControllerMixin(HTMLElement)))) -) { +declare class DatePicker extends DatePickerMixin(InputControlMixin(ThemableMixin(ElementMixin(HTMLElement)))) { addEventListener( type: K, listener: (this: DatePicker, ev: DatePickerEventMap[K]) => void, diff --git a/packages/date-picker/src/vaadin-date-picker.js b/packages/date-picker/src/vaadin-date-picker.js index fc51f02c4f..a003c82e5a 100644 --- a/packages/date-picker/src/vaadin-date-picker.js +++ b/packages/date-picker/src/vaadin-date-picker.js @@ -9,11 +9,10 @@ import './vaadin-date-picker-overlay.js'; import './vaadin-date-picker-overlay-content.js'; import { GestureEventListeners } from '@polymer/polymer/lib/mixins/gesture-event-listeners.js'; import { html, PolymerElement } from '@polymer/polymer/polymer-element.js'; -import { ControllerMixin } from '@vaadin/component-base/src/controller-mixin.js'; import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; -import { AriaLabelController } from '@vaadin/field-base/src/aria-label-controller.js'; import { InputControlMixin } from '@vaadin/field-base/src/input-control-mixin.js'; import { InputController } from '@vaadin/field-base/src/input-controller.js'; +import { LabelledInputController } from '@vaadin/field-base/src/labelled-input-controller.js'; import { inputFieldShared } from '@vaadin/field-base/src/styles/input-field-shared-styles.js'; import { registerStyles, ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; import { DatePickerMixin } from './vaadin-date-picker-mixin.js'; @@ -113,13 +112,12 @@ registerStyles('vaadin-date-picker', [inputFieldShared, datePickerStyles], { mod * @fires {CustomEvent} value-changed - Fired when the `value` property changes. * * @extends HTMLElement - * @mixes ControllerMixin * @mixes ElementMixin * @mixes ThemableMixin * @mixes InputControlMixin */ class DatePicker extends DatePickerMixin( - InputControlMixin(GestureEventListeners(ThemableMixin(ElementMixin(ControllerMixin(PolymerElement))))) + InputControlMixin(GestureEventListeners(ThemableMixin(ElementMixin(PolymerElement)))) ) { static get is() { return 'vaadin-date-picker'; @@ -214,7 +212,7 @@ class DatePicker extends DatePickerMixin( this.ariaTarget = input; }) ); - this.addController(new AriaLabelController(this, this.inputElement, this._labelNode)); + this.addController(new LabelledInputController(this.inputElement, this._labelNode)); } /** @private */ diff --git a/packages/date-time-picker/src/vaadin-date-time-picker.js b/packages/date-time-picker/src/vaadin-date-time-picker.js index 6150b36b77..e69617e627 100644 --- a/packages/date-time-picker/src/vaadin-date-time-picker.js +++ b/packages/date-time-picker/src/vaadin-date-time-picker.js @@ -405,14 +405,6 @@ class DateTimePicker extends FieldMixin(SlotMixin(DisabledMixin(ThemableMixin(El this.ariaTarget = this; } - /** - * Attribute used by `FieldMixin` to set accessible name. - * @protected - */ - get _ariaAttr() { - return 'aria-labelledby'; - } - /** @private */ __filterElements(node) { return node.nodeType === Node.ELEMENT_NODE; diff --git a/packages/field-base/index.d.ts b/packages/field-base/index.d.ts index 7dc89f9947..f217671f9a 100644 --- a/packages/field-base/index.d.ts +++ b/packages/field-base/index.d.ts @@ -1,12 +1,13 @@ -export { AriaLabelController } from './src/aria-label-controller.js'; export { CheckedMixin } from './src/checked-mixin.js'; export { DelegateFocusMixin } from './src/delegate-focus-mixin.js'; export { DelegateStateMixin } from './src/delegate-state-mixin.js'; +export { FieldAriaController } from './src/field-aria-controller.js'; export { FieldMixin } from './src/field-mixin.js'; export { InputController } from './src/input-controller.js'; export { InputControlMixin } from './src/input-control-mixin.js'; export { InputFieldMixin } from './src/input-field-mixin.js'; export { InputMixin } from './src/input-mixin.js'; +export { LabelledInputController } from './src/labelled-input-controller.js'; export { LabelMixin } from './src/label-mixin.js'; export { PatternMixin } from './src/pattern-mixin.js'; export { ShadowFocusMixin } from './src/shadow-focus-mixin.js'; diff --git a/packages/field-base/index.js b/packages/field-base/index.js index 7dc89f9947..f217671f9a 100644 --- a/packages/field-base/index.js +++ b/packages/field-base/index.js @@ -1,12 +1,13 @@ -export { AriaLabelController } from './src/aria-label-controller.js'; export { CheckedMixin } from './src/checked-mixin.js'; export { DelegateFocusMixin } from './src/delegate-focus-mixin.js'; export { DelegateStateMixin } from './src/delegate-state-mixin.js'; +export { FieldAriaController } from './src/field-aria-controller.js'; export { FieldMixin } from './src/field-mixin.js'; export { InputController } from './src/input-controller.js'; export { InputControlMixin } from './src/input-control-mixin.js'; export { InputFieldMixin } from './src/input-field-mixin.js'; export { InputMixin } from './src/input-mixin.js'; +export { LabelledInputController } from './src/labelled-input-controller.js'; export { LabelMixin } from './src/label-mixin.js'; export { PatternMixin } from './src/pattern-mixin.js'; export { ShadowFocusMixin } from './src/shadow-focus-mixin.js'; diff --git a/packages/field-base/src/field-aria-controller.d.ts b/packages/field-base/src/field-aria-controller.d.ts new file mode 100644 index 0000000000..6442a68468 --- /dev/null +++ b/packages/field-base/src/field-aria-controller.d.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright (c) 2021 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ + +/** + * A controller for managing ARIA attributes for a field element: + * either the component itself or slotted `` element. + */ +export class FieldAriaController { + constructor(host: HTMLElement); + + /** + * The controller host element. + */ + host: HTMLElement; + + /** + * Sets a target element to which ARIA attributes are added. + */ + setTarget(target: HTMLElement): void; + + /** + * Toggles the `aria-required` attribute on the target element + * if the target is the host component (e.g. a field group). + * Otherwise, it does nothing. + */ + setRequired(required: boolean): void; + + /** + * Links the target element with a slotted label element + * via the target's attribute `aria-labelledby`. + * + * To unlink the previous slotted label element, pass `null` as `labelId`. + */ + setLabelId(labelId: string | null): void; + + /** + * Links the target element with a slotted error element via the target's attribute: + * - `aria-labelledby` if the target is the host component (e.g a field group). + * - `aria-describedby` otherwise. + * + * To unlink the previous slotted error element, pass `null` as `errorId`. + */ + setErrorId(errorId: string | null): void; + + /** + * Links the target element with a slotted helper element via the target's attribute: + * - `aria-labelledby` if the target is the host component (e.g a field group). + * - `aria-describedby` otherwise. + * + * To unlink the previous slotted helper element, pass `null` as `helperId`. + */ + setHelperId(helperId: string | null): void; +} diff --git a/packages/field-base/src/field-aria-controller.js b/packages/field-base/src/field-aria-controller.js new file mode 100644 index 0000000000..5ba0747cf9 --- /dev/null +++ b/packages/field-base/src/field-aria-controller.js @@ -0,0 +1,175 @@ +/** + * @license + * Copyright (c) 2021 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ + +/** + * A controller for managing ARIA attributes for a field element: + * either the component itself or slotted `` element. + */ +export class FieldAriaController { + constructor(host) { + this.host = host; + this.__required = false; + } + + /** + * Sets a target element to which ARIA attributes are added. + * + * @param {HTMLElement} target + */ + setTarget(target) { + this.__target = target; + this.__setAriaRequiredAttribute(this.__required); + this.__setLabelIdToAriaAttribute(this.__labelId); + this.__setErrorIdToAriaAttribute(this.__errorId); + this.__setHelperIdToAriaAttribute(this.__helperId); + } + + /** + * Toggles the `aria-required` attribute on the target element + * if the target is the host component (e.g. a field group). + * Otherwise, it does nothing. + * + * @param {boolean} required + */ + setRequired(required) { + this.__setAriaRequiredAttribute(required); + this.__required = required; + } + + /** + * Links the target element with a slotted label element + * via the target's attribute `aria-labelledby`. + * + * To unlink the previous slotted label element, pass `null` as `labelId`. + * + * @param {string | null} labelId + */ + setLabelId(labelId) { + this.__setLabelIdToAriaAttribute(labelId, this.__labelId); + this.__labelId = labelId; + } + + /** + * Links the target element with a slotted error element via the target's attribute: + * - `aria-labelledby` if the target is the host component (e.g a field group). + * - `aria-describedby` otherwise. + * + * To unlink the previous slotted error element, pass `null` as `errorId`. + * + * @param {string | null} errorId + */ + setErrorId(errorId) { + this.__setErrorIdToAriaAttribute(errorId, this.__errorId); + this.__errorId = errorId; + } + + /** + * Links the target element with a slotted helper element via the target's attribute: + * - `aria-labelledby` if the target is the host component (e.g a field group). + * - `aria-describedby` otherwise. + * + * To unlink the previous slotted helper element, pass `null` as `helperId`. + * + * @param {string | null} helperId + */ + setHelperId(helperId) { + this.__setHelperIdToAriaAttribute(helperId, this.__helperId); + this.__helperId = helperId; + } + + /** + * `true` if the target element is the host component itself, `false` otherwise. + * + * @return {boolean} + * @private + */ + get __isGroupField() { + return this.__target === this.host; + } + + /** + * @param {string | null | undefined} labelId + * @param {string | null | undefined} oldLabelId + * @private + */ + __setLabelIdToAriaAttribute(labelId, oldLabelId) { + this.__setAriaAttributeId('aria-labelledby', labelId, oldLabelId); + } + + /** + * @param {string | null | undefined} errorId + * @param {string | null | undefined} oldErrorId + * @private + */ + __setErrorIdToAriaAttribute(errorId, oldErrorId) { + // For groups, add all IDs to aria-labelledby rather than aria-describedby - + // that should guarantee that it's announced when the group is entered. + if (this.__isGroupField) { + this.__setAriaAttributeId('aria-labelledby', errorId, oldErrorId); + } else { + this.__setAriaAttributeId('aria-describedby', errorId, oldErrorId); + } + } + + /** + * @param {string | null | undefined} helperId + * @param {string | null | undefined} oldHelperId + * @private + */ + __setHelperIdToAriaAttribute(helperId, oldHelperId) { + // For groups, add all IDs to aria-labelledby rather than aria-describedby - + // that should guarantee that it's announced when the group is entered. + if (this.__isGroupField) { + this.__setAriaAttributeId('aria-labelledby', helperId, oldHelperId); + } else { + this.__setAriaAttributeId('aria-describedby', helperId, oldHelperId); + } + } + + /** + * @param {boolean} required + * @private + */ + __setAriaRequiredAttribute(required) { + if (!this.__target) { + return; + } + + if (!this.__isGroupField) { + // native or