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