Merge pull request #1325 from ing-bank/fix/fieldsetLabelAsChildSuffix
fix(form-core): a11y / types / test fixes
This commit is contained in:
commit
2241f72f20
16 changed files with 570 additions and 331 deletions
5
.changeset/rotten-dots-argue.md
Normal file
5
.changeset/rotten-dots-argue.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/form-core': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
**form-core**: fieldset label as child label suffix. Mimics native fieldset a11y
|
||||||
8
.changeset/rude-ducks-hide.md
Normal file
8
.changeset/rude-ducks-hide.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
'@lion/form-core': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
**form-core**:
|
||||||
|
|
||||||
|
- cleanup group > child descriptions on disconnectedCallback
|
||||||
|
- reenable tests
|
||||||
6
.changeset/slimy-items-wait.md
Normal file
6
.changeset/slimy-items-wait.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
'@lion/combobox': patch
|
||||||
|
'@lion/form-core': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
**combobox**: enabled and fixed types
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
// @ts-nocheck there's an error in cli that cannot be reproduced locally
|
|
||||||
import { html, css, browserDetection } from '@lion/core';
|
import { html, css, browserDetection } from '@lion/core';
|
||||||
import { OverlayMixin, withDropdownConfig } from '@lion/overlays';
|
import { OverlayMixin, withDropdownConfig } from '@lion/overlays';
|
||||||
import { LionListbox } from '@lion/listbox';
|
import { LionListbox } from '@lion/listbox';
|
||||||
|
|
@ -12,6 +11,8 @@ import { LionListbox } from '@lion/listbox';
|
||||||
* @typedef {import('@lion/listbox').LionOptions} LionOptions
|
* @typedef {import('@lion/listbox').LionOptions} LionOptions
|
||||||
* @typedef {import('@lion/overlays/types/OverlayConfig').OverlayConfig} OverlayConfig
|
* @typedef {import('@lion/overlays/types/OverlayConfig').OverlayConfig} OverlayConfig
|
||||||
* @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap
|
* @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap
|
||||||
|
* @typedef {import('@lion/form-core/types/choice-group/ChoiceInputMixinTypes').ChoiceInputHost} ChoiceInputHost
|
||||||
|
* @typedef {import('@lion/form-core/types/FormControlMixinTypes').FormControlHost} FormControlHost
|
||||||
* @typedef {import('../types/SelectionDisplay').SelectionDisplay} SelectionDisplay
|
* @typedef {import('../types/SelectionDisplay').SelectionDisplay} SelectionDisplay
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ import { LionListbox } from '@lion/listbox';
|
||||||
* LionCombobox: implements the wai-aria combobox design pattern and integrates it as a Lion
|
* LionCombobox: implements the wai-aria combobox design pattern and integrates it as a Lion
|
||||||
* FormControl
|
* FormControl
|
||||||
*/
|
*/
|
||||||
|
// @ts-expect-error static properties are not compatible
|
||||||
export class LionCombobox extends OverlayMixin(LionListbox) {
|
export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -180,7 +182,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
* @configure FormControlMixin
|
* @configure FormControlMixin
|
||||||
* Will tell FormControlMixin that a11y wrt labels / descriptions / feedback
|
* Will tell FormControlMixin that a11y wrt labels / descriptions / feedback
|
||||||
* should be applied here.
|
* should be applied here.
|
||||||
* @protected
|
|
||||||
*/
|
*/
|
||||||
get _inputNode() {
|
get _inputNode() {
|
||||||
if (this._ariaVersion === '1.1') {
|
if (this._ariaVersion === '1.1') {
|
||||||
|
|
@ -413,8 +414,9 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
* return options.currentValue.length > 4 && super._showOverlayCondition(options);
|
* return options.currentValue.length > 4 && super._showOverlayCondition(options);
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* @param {{ currentValue: string, lastKey:string }} options
|
* @param {{ currentValue: string, lastKey:string|undefined }} options
|
||||||
* @protected
|
* @protected
|
||||||
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line class-methods-use-this
|
// eslint-disable-next-line class-methods-use-this
|
||||||
_showOverlayCondition({ lastKey }) {
|
_showOverlayCondition({ lastKey }) {
|
||||||
|
|
@ -423,7 +425,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
}
|
}
|
||||||
// when no keyboard action involved (on focused change), return current opened state
|
// when no keyboard action involved (on focused change), return current opened state
|
||||||
if (!lastKey) {
|
if (!lastKey) {
|
||||||
return this.opened;
|
return /** @type {boolean} */ (this.opened);
|
||||||
}
|
}
|
||||||
const doNotShowOn = ['Tab', 'Esc', 'Enter'];
|
const doNotShowOn = ['Tab', 'Esc', 'Enter'];
|
||||||
return !doNotShowOn.includes(lastKey);
|
return !doNotShowOn.includes(lastKey);
|
||||||
|
|
@ -505,8 +507,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
* should indeed not repropagate as normally. If there is no elements checked, this will be the only
|
* should indeed not repropagate as normally. If there is no elements checked, this will be the only
|
||||||
* model-value-changed event that gets received, and we should repropagate it.
|
* model-value-changed event that gets received, and we should repropagate it.
|
||||||
*
|
*
|
||||||
* @param {EventTarget & import('../types/choice-group/ChoiceInputMixinTypes').ChoiceInputHost} target
|
* @param {FormControlHost} target
|
||||||
* @protected
|
|
||||||
*/
|
*/
|
||||||
_repropagationCondition(target) {
|
_repropagationCondition(target) {
|
||||||
return super._repropagationCondition(target) || this.formElements.every(el => !el.checked);
|
return super._repropagationCondition(target) || this.formElements.every(el => !el.checked);
|
||||||
|
|
@ -801,6 +802,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
* @overridable
|
* @overridable
|
||||||
* @param {string|string[]} modelValue
|
* @param {string|string[]} modelValue
|
||||||
* @param {string|string[]} oldModelValue
|
* @param {string|string[]} oldModelValue
|
||||||
|
* @param {{phase?:string}} config
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
|
@ -818,17 +820,17 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
_syncToTextboxMultiple(modelValue, oldModelValue = []) {
|
_syncToTextboxMultiple(modelValue, oldModelValue = []) {
|
||||||
const diff = modelValue.filter(x => !oldModelValue.includes(x));
|
const diff = modelValue.filter(x => !oldModelValue.includes(x)).toString();
|
||||||
this._setTextboxValue(diff); // or last selected value?
|
this._setTextboxValue(diff); // or last selected value?
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @override FormControlMixin - add form-control to [slot=input] instead of _inputNode
|
* @override FormControlMixin - add form-control to [slot=input] instead of _inputNode
|
||||||
* @protected
|
|
||||||
*/
|
*/
|
||||||
_enhanceLightDomClasses() {
|
_enhanceLightDomClasses() {
|
||||||
if (this.querySelector('[slot=input]')) {
|
const formControl = /** @type {HTMLInputElement} */ (this.querySelector('[slot=input]'));
|
||||||
this.querySelector('[slot=input]').classList.add('form-control');
|
if (formControl) {
|
||||||
|
formControl.classList.add('form-control');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1562,15 +1562,12 @@ describe('lion-combobox', () => {
|
||||||
it('synchronizes autocomplete option to textbox', async () => {
|
it('synchronizes autocomplete option to textbox', async () => {
|
||||||
let el;
|
let el;
|
||||||
[el] = await fruitFixture({ autocomplete: 'both' });
|
[el] = await fruitFixture({ autocomplete: 'both' });
|
||||||
// @ts-expect-error
|
|
||||||
expect(el._inputNode.getAttribute('aria-autocomplete')).to.equal('both');
|
expect(el._inputNode.getAttribute('aria-autocomplete')).to.equal('both');
|
||||||
|
|
||||||
[el] = await fruitFixture({ autocomplete: 'list' });
|
[el] = await fruitFixture({ autocomplete: 'list' });
|
||||||
// @ts-expect-error
|
|
||||||
expect(el._inputNode.getAttribute('aria-autocomplete')).to.equal('list');
|
expect(el._inputNode.getAttribute('aria-autocomplete')).to.equal('list');
|
||||||
|
|
||||||
[el] = await fruitFixture({ autocomplete: 'none' });
|
[el] = await fruitFixture({ autocomplete: 'none' });
|
||||||
// @ts-expect-error
|
|
||||||
expect(el._inputNode.getAttribute('aria-autocomplete')).to.equal('none');
|
expect(el._inputNode.getAttribute('aria-autocomplete')).to.equal('none');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Unparseable } from './validate/Unparseable.js';
|
||||||
* @typedef {import('@lion/core').CSSResultArray} CSSResultArray
|
* @typedef {import('@lion/core').CSSResultArray} CSSResultArray
|
||||||
* @typedef {import('@lion/core').nothing} nothing
|
* @typedef {import('@lion/core').nothing} nothing
|
||||||
* @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap
|
* @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap
|
||||||
|
* @typedef {import('../types/choice-group/ChoiceInputMixinTypes').ChoiceInputHost} ChoiceInputHost
|
||||||
* @typedef {import('../types/FormControlMixinTypes.js').FormControlMixin} FormControlMixin
|
* @typedef {import('../types/FormControlMixinTypes.js').FormControlMixin} FormControlMixin
|
||||||
* @typedef {import('../types/FormControlMixinTypes.js').ModelValueEventDetails} ModelValueEventDetails
|
* @typedef {import('../types/FormControlMixinTypes.js').ModelValueEventDetails} ModelValueEventDetails
|
||||||
*/
|
*/
|
||||||
|
|
@ -321,10 +322,10 @@ const FormControlMixinImplementation = superclass =>
|
||||||
additionalSlots.forEach(additionalSlot => {
|
additionalSlots.forEach(additionalSlot => {
|
||||||
const element = this.__getDirectSlotChild(additionalSlot);
|
const element = this.__getDirectSlotChild(additionalSlot);
|
||||||
if (element) {
|
if (element) {
|
||||||
if (element.hasAttribute('data-label') === true) {
|
if (element.hasAttribute('data-label')) {
|
||||||
this.addToAriaLabelledBy(element, { idPrefix: additionalSlot });
|
this.addToAriaLabelledBy(element, { idPrefix: additionalSlot });
|
||||||
}
|
}
|
||||||
if (element.hasAttribute('data-description') === true) {
|
if (element.hasAttribute('data-description')) {
|
||||||
this.addToAriaDescribedBy(element, { idPrefix: additionalSlot });
|
this.addToAriaDescribedBy(element, { idPrefix: additionalSlot });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -346,6 +347,7 @@ const FormControlMixinImplementation = superclass =>
|
||||||
if (reorder) {
|
if (reorder) {
|
||||||
const insideNodes = nodes.filter(n => this.contains(n));
|
const insideNodes = nodes.filter(n => this.contains(n));
|
||||||
const outsideNodes = nodes.filter(n => !this.contains(n));
|
const outsideNodes = nodes.filter(n => !this.contains(n));
|
||||||
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
nodes = [...getAriaElementsInRightDomOrder(insideNodes), ...outsideNodes];
|
nodes = [...getAriaElementsInRightDomOrder(insideNodes), ...outsideNodes];
|
||||||
}
|
}
|
||||||
|
|
@ -691,6 +693,8 @@ const FormControlMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* This function exposes descripion elements that a FormGroup should expose to its
|
||||||
|
* children. See FormGroupMixin.__getAllDescriptionElementsInParentChain()
|
||||||
* @return {Array.<HTMLElement|undefined>}
|
* @return {Array.<HTMLElement|undefined>}
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
|
|
@ -704,12 +708,7 @@ const FormControlMixinImplementation = superclass =>
|
||||||
* @param {HTMLElement} element
|
* @param {HTMLElement} element
|
||||||
* @param {{idPrefix?:string; reorder?: boolean}} customConfig
|
* @param {{idPrefix?:string; reorder?: boolean}} customConfig
|
||||||
*/
|
*/
|
||||||
addToAriaLabelledBy(element, customConfig = {}) {
|
addToAriaLabelledBy(element, { idPrefix = '', reorder = true } = {}) {
|
||||||
const { idPrefix, reorder } = {
|
|
||||||
reorder: true,
|
|
||||||
...customConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
element.id = element.id || `${idPrefix}-${this._inputId}`;
|
element.id = element.id || `${idPrefix}-${this._inputId}`;
|
||||||
if (!this._ariaLabelledNodes.includes(element)) {
|
if (!this._ariaLabelledNodes.includes(element)) {
|
||||||
|
|
@ -720,18 +719,27 @@ const FormControlMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meant for Application Developers wanting to delete from aria-labelledby attribute.
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
*/
|
||||||
|
removeFromAriaLabelledBy(element) {
|
||||||
|
if (this._ariaLabelledNodes.includes(element)) {
|
||||||
|
this._ariaLabelledNodes.splice(this._ariaLabelledNodes.indexOf(element), 1);
|
||||||
|
this._ariaLabelledNodes = [...this._ariaLabelledNodes];
|
||||||
|
|
||||||
|
// This value will be read when we need to reflect to attr
|
||||||
|
/** @type {boolean} */
|
||||||
|
this.__reorderAriaLabelledNodes = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meant for Application Developers wanting to add to aria-describedby attribute.
|
* Meant for Application Developers wanting to add to aria-describedby attribute.
|
||||||
* @param {HTMLElement} element
|
* @param {HTMLElement} element
|
||||||
* @param {{idPrefix?:string; reorder?: boolean}} customConfig
|
* @param {{idPrefix?:string; reorder?: boolean}} customConfig
|
||||||
*/
|
*/
|
||||||
addToAriaDescribedBy(element, customConfig = {}) {
|
addToAriaDescribedBy(element, { idPrefix = '', reorder = true } = {}) {
|
||||||
const { idPrefix, reorder } = {
|
|
||||||
// chronologically sorts children of host element('this')
|
|
||||||
reorder: true,
|
|
||||||
...customConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
element.id = element.id || `${idPrefix}-${this._inputId}`;
|
element.id = element.id || `${idPrefix}-${this._inputId}`;
|
||||||
if (!this._ariaDescribedNodes.includes(element)) {
|
if (!this._ariaDescribedNodes.includes(element)) {
|
||||||
|
|
@ -742,6 +750,20 @@ const FormControlMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meant for Application Developers wanting to delete from aria-describedby attribute.
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
*/
|
||||||
|
removeFromAriaDescribedBy(element) {
|
||||||
|
if (this._ariaDescribedNodes.includes(element)) {
|
||||||
|
this._ariaDescribedNodes.splice(this._ariaDescribedNodes.indexOf(element), 1);
|
||||||
|
this._ariaDescribedNodes = [...this._ariaDescribedNodes];
|
||||||
|
// This value will be read when we need to reflect to attr
|
||||||
|
/** @type {boolean} */
|
||||||
|
this.__reorderAriaLabelledNodes = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} slotName
|
* @param {string} slotName
|
||||||
* @return {HTMLElement | undefined}
|
* @return {HTMLElement | undefined}
|
||||||
|
|
@ -862,8 +884,9 @@ const FormControlMixinImplementation = superclass =>
|
||||||
/**
|
/**
|
||||||
* TODO: Extend this in choice group so that target is always a choice input and multipleChoice exists.
|
* TODO: Extend this in choice group so that target is always a choice input and multipleChoice exists.
|
||||||
* This will fix the types and reduce the need for ignores/expect-errors
|
* This will fix the types and reduce the need for ignores/expect-errors
|
||||||
* @param {EventTarget & import('../types/choice-group/ChoiceInputMixinTypes').ChoiceInputHost} target
|
* @param {EventTarget & ChoiceInputHost} target
|
||||||
* @protected
|
* @protected
|
||||||
|
* @overridable
|
||||||
*/
|
*/
|
||||||
_repropagationCondition(target) {
|
_repropagationCondition(target) {
|
||||||
return !(
|
return !(
|
||||||
|
|
|
||||||
|
|
@ -114,9 +114,7 @@ const ChoiceInputMixinImplementation = superclass =>
|
||||||
|
|
||||||
if (
|
if (
|
||||||
changedProperties.has('name') &&
|
changedProperties.has('name') &&
|
||||||
// @ts-expect-error not all choice inputs have a parent form group, since this mixin does not have a strict contract with the registration system
|
|
||||||
this._parentFormGroup &&
|
this._parentFormGroup &&
|
||||||
// @ts-expect-error
|
|
||||||
this._parentFormGroup.name !== this.name
|
this._parentFormGroup.name !== this.name
|
||||||
) {
|
) {
|
||||||
this._syncNameToParentFormGroup();
|
this._syncNameToParentFormGroup();
|
||||||
|
|
@ -251,7 +249,6 @@ const ChoiceInputMixinImplementation = superclass =>
|
||||||
_syncNameToParentFormGroup() {
|
_syncNameToParentFormGroup() {
|
||||||
// @ts-expect-error not all choice inputs have a name prop, because this mixin does not have a strict contract with form control mixin
|
// @ts-expect-error not all choice inputs have a name prop, because this mixin does not have a strict contract with form control mixin
|
||||||
if (this._parentFormGroup.tagName.includes(this.tagName)) {
|
if (this._parentFormGroup.tagName.includes(this.tagName)) {
|
||||||
// @ts-expect-error
|
|
||||||
this.name = this._parentFormGroup.name;
|
this.name = this._parentFormGroup.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ const FormGroupMixinImplementation = superclass =>
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
// inputNode = this, which always requires a value prop
|
// ._inputNode = this, which always requires a value prop
|
||||||
this.value = '';
|
this.value = '';
|
||||||
|
|
||||||
this.disabled = false;
|
this.disabled = false;
|
||||||
|
|
@ -146,6 +146,8 @@ const FormGroupMixinImplementation = superclass =>
|
||||||
this.addEventListener('validate-performed', this.__onChildValidatePerformed);
|
this.addEventListener('validate-performed', this.__onChildValidatePerformed);
|
||||||
|
|
||||||
this.defaultValidators = [new FormElementsHaveNoError()];
|
this.defaultValidators = [new FormElementsHaveNoError()];
|
||||||
|
|
||||||
|
this.__descriptionElementsInParentChain = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -166,6 +168,7 @@ const FormGroupMixinImplementation = superclass =>
|
||||||
document.removeEventListener('click', this._checkForOutsideClick);
|
document.removeEventListener('click', this._checkForOutsideClick);
|
||||||
this.__hasActiveOutsideClickHandling = false;
|
this.__hasActiveOutsideClickHandling = false;
|
||||||
}
|
}
|
||||||
|
this.__descriptionElementsInParentChain.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
__initInteractionStates() {
|
__initInteractionStates() {
|
||||||
|
|
@ -425,20 +428,58 @@ const FormGroupMixinImplementation = superclass =>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {FormControl} child
|
* Traverses the _parentFormGroup tree, and gathers all aria description elements
|
||||||
|
* (feedback and helptext) that should be provided to children.
|
||||||
|
*
|
||||||
|
* In the example below, when the input for 'street' has focus, a screenreader user
|
||||||
|
* would hear the #group-error.
|
||||||
|
* In case one of the inputs was in error state as well, the SR user would
|
||||||
|
* first hear the local error, followed by #group-error
|
||||||
|
* @example
|
||||||
|
* <lion-fieldset name="address">
|
||||||
|
* <lion-input name="street" label="Street" .modelValue="${'Park Avenue'}"></lion-input>
|
||||||
|
* <lion-input name="number" label="Number" .modelValue="${100}">...</lion-input>
|
||||||
|
* <div slot="feedback" id="group-error">
|
||||||
|
* Park Avenue only has numbers up to 80
|
||||||
|
* </div>
|
||||||
|
* </lion-fieldset>
|
||||||
*/
|
*/
|
||||||
__linkChildrenMessagesToParent(child) {
|
__storeAllDescriptionElementsInParentChain() {
|
||||||
// aria-describedby of (nested) children
|
|
||||||
const unTypedThis = /** @type {unknown} */ (this);
|
const unTypedThis = /** @type {unknown} */ (this);
|
||||||
let parent = /** @type {FormControlHost & { _parentFormGroup:any }} */ (unTypedThis);
|
let parent = /** @type {FormControlHost & { _parentFormGroup:any }} */ (unTypedThis);
|
||||||
const ctor = /** @type {typeof FormGroupMixin} */ (this.constructor);
|
|
||||||
while (parent) {
|
while (parent) {
|
||||||
ctor._addDescriptionElementIdsToField(child, parent._getAriaDescriptionElements());
|
const descriptionElements = parent._getAriaDescriptionElements();
|
||||||
|
const orderedEls = getAriaElementsInRightDomOrder(descriptionElements, { reverse: true });
|
||||||
|
orderedEls.forEach(el => {
|
||||||
|
this.__descriptionElementsInParentChain.add(el);
|
||||||
|
});
|
||||||
// Also check if the newly added child needs to refer grandparents
|
// Also check if the newly added child needs to refer grandparents
|
||||||
parent = parent._parentFormGroup;
|
parent = parent._parentFormGroup;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FormControl} child
|
||||||
|
*/
|
||||||
|
__linkParentMessages(child) {
|
||||||
|
this.__descriptionElementsInParentChain.forEach(el => {
|
||||||
|
if (typeof child.addToAriaDescribedBy === 'function') {
|
||||||
|
child.addToAriaDescribedBy(el, { reorder: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FormControl} child
|
||||||
|
*/
|
||||||
|
__unlinkParentMessages(child) {
|
||||||
|
this.__descriptionElementsInParentChain.forEach(el => {
|
||||||
|
if (typeof child.removeFromAriaDescribedBy === 'function') {
|
||||||
|
child.removeFromAriaDescribedBy(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @override of FormRegistrarMixin.
|
* @override of FormRegistrarMixin.
|
||||||
* @desc Connects ValidateMixin and DisabledMixin
|
* @desc Connects ValidateMixin and DisabledMixin
|
||||||
|
|
@ -451,9 +492,15 @@ const FormGroupMixinImplementation = superclass =>
|
||||||
if (this.disabled) {
|
if (this.disabled) {
|
||||||
child.makeRequestToBeDisabled();
|
child.makeRequestToBeDisabled();
|
||||||
}
|
}
|
||||||
// TODO: Unlink in removeFormElement
|
if (!this.__descriptionElementsInParentChain.size) {
|
||||||
this.__linkChildrenMessagesToParent(child);
|
this.__storeAllDescriptionElementsInParentChain();
|
||||||
|
}
|
||||||
|
this.__linkParentMessages(child);
|
||||||
this.validate({ clearCurrentResult: true });
|
this.validate({ clearCurrentResult: true });
|
||||||
|
|
||||||
|
if (typeof child.addToAriaLabelledBy === 'function' && this._labelNode) {
|
||||||
|
child.addToAriaLabelledBy(this._labelNode, { reorder: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -464,28 +511,18 @@ const FormGroupMixinImplementation = superclass =>
|
||||||
return this._getFromAllFormElements('_initialModelValue');
|
return this._getFromAllFormElements('_initialModelValue');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add aria-describedby to child element(field), so that it points to feedback/help-text of
|
|
||||||
* parent(fieldset)
|
|
||||||
* @param {FormControl} field - the child: lion-field/lion-input/lion-textarea
|
|
||||||
* @param {HTMLElement[]} descriptionElements - description elements like feedback and help-text
|
|
||||||
*/
|
|
||||||
static _addDescriptionElementIdsToField(field, descriptionElements) {
|
|
||||||
const orderedEls = getAriaElementsInRightDomOrder(descriptionElements, { reverse: true });
|
|
||||||
orderedEls.forEach(el => {
|
|
||||||
if (field.addToAriaDescribedBy) {
|
|
||||||
field.addToAriaDescribedBy(el, { reorder: false });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @override of FormRegistrarMixin. Connects ValidateMixin
|
* @override of FormRegistrarMixin. Connects ValidateMixin
|
||||||
* @param {FormRegisteringHost} el
|
* @param {FormRegisteringHost & FormControl} el
|
||||||
*/
|
*/
|
||||||
removeFormElement(el) {
|
removeFormElement(el) {
|
||||||
super.removeFormElement(el);
|
super.removeFormElement(el);
|
||||||
this.validate({ clearCurrentResult: true });
|
this.validate({ clearCurrentResult: true });
|
||||||
|
|
||||||
|
if (typeof el.removeFromAriaLabelledBy === 'function' && this._labelNode) {
|
||||||
|
el.removeFromAriaLabelledBy(this._labelNode, { reorder: false });
|
||||||
|
}
|
||||||
|
this.__unlinkParentMessages(el);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ const FormRegistrarMixinImplementation = superclass =>
|
||||||
*/
|
*/
|
||||||
addFormElement(child, indexToInsertAt) {
|
addFormElement(child, indexToInsertAt) {
|
||||||
// This is a way to let the child element (a lion-fieldset or lion-field) know, about its parent
|
// This is a way to let the child element (a lion-fieldset or lion-field) know, about its parent
|
||||||
|
// @ts-expect-error FormControl needs to be at the bottom of the hierarchy
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
child._parentFormGroup = this;
|
child._parentFormGroup = this;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ customElements.define('choice-input-foo', ChoiceInputFoo);
|
||||||
class ChoiceInputBar extends ChoiceInputMixin(LionInput) {
|
class ChoiceInputBar extends ChoiceInputMixin(LionInput) {
|
||||||
_syncNameToParentFormGroup() {
|
_syncNameToParentFormGroup() {
|
||||||
// Always sync, without conditions
|
// Always sync, without conditions
|
||||||
// @ts-expect-error
|
|
||||||
this.name = this._parentFormGroup.name;
|
this.name = this._parentFormGroup.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import { LionInput } from '@lion/input';
|
||||||
import '@lion/form-core/define';
|
import '@lion/form-core/define';
|
||||||
import { FormGroupMixin } from '../../src/form-group/FormGroupMixin.js';
|
import { FormGroupMixin } from '../../src/form-group/FormGroupMixin.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {import('@lion/form-core').LionField} LionField
|
||||||
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ tagString?: string, childTagString?:string }} [cfg]
|
* @param {{ tagString?: string, childTagString?:string }} [cfg]
|
||||||
*/
|
*/
|
||||||
|
|
@ -40,7 +44,7 @@ export function runFormGroupMixinInputSuite(cfg = {}) {
|
||||||
localizeTearDown();
|
localizeTearDown();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('FormGroupMixin with LionInput', () => {
|
describe('FormGroupMixin with LionField', () => {
|
||||||
it('serializes undefined values as "" (nb radios/checkboxes are always serialized)', async () => {
|
it('serializes undefined values as "" (nb radios/checkboxes are always serialized)', async () => {
|
||||||
const fieldset = /** @type {FormGroup} */ (await fixture(html`
|
const fieldset = /** @type {FormGroup} */ (await fixture(html`
|
||||||
<${tag}>
|
<${tag}>
|
||||||
|
|
@ -55,6 +59,34 @@ export function runFormGroupMixinInputSuite(cfg = {}) {
|
||||||
'custom[]': ['custom 1', ''],
|
'custom[]': ['custom 1', ''],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('suffixes child labels with group label, just like in <fieldset>', async () => {
|
||||||
|
const el = /** @type {FormGroup} */ (await fixture(html`
|
||||||
|
<${tag} label="set">
|
||||||
|
<${childTag} name="A" label="fieldA"></${childTag}>
|
||||||
|
<${childTag} name="B" label="fieldB"></${childTag}>
|
||||||
|
</${tag}>
|
||||||
|
`));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {LionInput} formControl
|
||||||
|
*/
|
||||||
|
function getLabels(formControl) {
|
||||||
|
return /** @type {string} */ (formControl._inputNode.getAttribute('aria-labelledby')).split(
|
||||||
|
' ',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const field1 = el.formElements[0];
|
||||||
|
const field2 = el.formElements[1];
|
||||||
|
|
||||||
|
expect(getLabels(field1)).to.eql([field1._labelNode.id, el._labelNode.id]);
|
||||||
|
expect(getLabels(field2)).to.eql([field2._labelNode.id, el._labelNode.id]);
|
||||||
|
|
||||||
|
// Test the cleanup on disconnected
|
||||||
|
el.removeChild(field1);
|
||||||
|
await field1.updateComplete;
|
||||||
|
expect(getLabels(field1)).to.eql([field1._labelNode.id]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Screen reader relations (aria-describedby) for child inputs and fieldsets', () => {
|
describe('Screen reader relations (aria-describedby) for child inputs and fieldsets', () => {
|
||||||
|
|
@ -112,8 +144,11 @@ export function runFormGroupMixinInputSuite(cfg = {}) {
|
||||||
return dom;
|
return dom;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line no-shadow
|
childAriaTest = async (
|
||||||
childAriaTest = (/** @type {FormGroup} */ childAriaFixture) => {
|
// eslint-disable-next-line no-shadow
|
||||||
|
/** @type {FormGroup} */ childAriaFixture,
|
||||||
|
{ cleanupPhase = false } = {},
|
||||||
|
) => {
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
// Message elements: all elements pointed at by inputs
|
// Message elements: all elements pointed at by inputs
|
||||||
const msg_l1_g = /** @type {FormGroup} */ (childAriaFixture.querySelector('#msg_l1_g'));
|
const msg_l1_g = /** @type {FormGroup} */ (childAriaFixture.querySelector('#msg_l1_g'));
|
||||||
|
|
@ -124,75 +159,152 @@ export function runFormGroupMixinInputSuite(cfg = {}) {
|
||||||
const msg_l2_fb = /** @type {FormChild} */ (childAriaFixture.querySelector('#msg_l2_fb'));
|
const msg_l2_fb = /** @type {FormChild} */ (childAriaFixture.querySelector('#msg_l2_fb'));
|
||||||
|
|
||||||
// Field elements: all inputs pointing to message elements
|
// Field elements: all inputs pointing to message elements
|
||||||
const input_l1_fa = /** @type {FormChild} */ (childAriaFixture.querySelector(
|
const input_l1_fa = /** @type {HTMLInputElement} */ (childAriaFixture.querySelector(
|
||||||
'input[name=l1_fa]',
|
'input[name=l1_fa]',
|
||||||
));
|
));
|
||||||
const input_l1_fb = /** @type {FormChild} */ (childAriaFixture.querySelector(
|
const input_l1_fb = /** @type {HTMLInputElement} */ (childAriaFixture.querySelector(
|
||||||
'input[name=l1_fb]',
|
'input[name=l1_fb]',
|
||||||
));
|
));
|
||||||
const input_l2_fa = /** @type {FormChild} */ (childAriaFixture.querySelector(
|
const input_l2_fa = /** @type {HTMLInputElement} */ (childAriaFixture.querySelector(
|
||||||
'input[name=l2_fa]',
|
'input[name=l2_fa]',
|
||||||
));
|
));
|
||||||
const input_l2_fb = /** @type {FormChild} */ (childAriaFixture.querySelector(
|
const input_l2_fb = /** @type {HTMLInputElement} */ (childAriaFixture.querySelector(
|
||||||
'input[name=l2_fb]',
|
'input[name=l2_fb]',
|
||||||
));
|
));
|
||||||
|
|
||||||
|
if (!cleanupPhase) {
|
||||||
|
// 'L1' fields (inside lion-fieldset[name="l1_g"]) should point to l1(group) msg
|
||||||
|
expect(input_l1_fa.getAttribute('aria-describedby')).to.contain(
|
||||||
|
msg_l1_g.id,
|
||||||
|
'l1 input(a) refers parent/group',
|
||||||
|
);
|
||||||
|
expect(input_l1_fb.getAttribute('aria-describedby')).to.contain(
|
||||||
|
msg_l1_g.id,
|
||||||
|
'l1 input(b) refers parent/group',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also check that aria-describedby of the inputs are not overridden (this relation was
|
||||||
|
// put there in lion-input(using lion-field)).
|
||||||
|
expect(input_l1_fa.getAttribute('aria-describedby')).to.contain(
|
||||||
|
msg_l1_fa.id,
|
||||||
|
'l1 input(a) refers local field',
|
||||||
|
);
|
||||||
|
expect(input_l1_fb.getAttribute('aria-describedby')).to.contain(
|
||||||
|
msg_l1_fb.id,
|
||||||
|
'l1 input(b) refers local field',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also make feedback element point to nested fieldset inputs
|
||||||
|
expect(input_l2_fa.getAttribute('aria-describedby')).to.contain(
|
||||||
|
msg_l1_g.id,
|
||||||
|
'l2 input(a) refers grandparent/group.group',
|
||||||
|
);
|
||||||
|
expect(input_l2_fb.getAttribute('aria-describedby')).to.contain(
|
||||||
|
msg_l1_g.id,
|
||||||
|
'l2 input(b) refers grandparent/group.group',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check order: the nearest ('dom wise': so 1. local, 2. parent, 3. grandparent) message
|
||||||
|
// should be read first by screen reader
|
||||||
|
const dA = /** @type {string} */ (input_l2_fa.getAttribute('aria-describedby'));
|
||||||
|
expect(
|
||||||
|
// @ts-expect-error
|
||||||
|
dA.indexOf(msg_l2_fa.id) < dA.indexOf(msg_l2_g.id) < dA.indexOf(msg_l1_g.id),
|
||||||
|
).to.equal(true, 'order of ids');
|
||||||
|
const dB = input_l2_fb.getAttribute('aria-describedby');
|
||||||
|
expect(
|
||||||
|
// @ts-expect-error
|
||||||
|
dB.indexOf(msg_l2_fb.id) < dB.indexOf(msg_l2_g.id) < dB.indexOf(msg_l1_g.id),
|
||||||
|
).to.equal(true, 'order of ids');
|
||||||
|
} else {
|
||||||
|
// cleanupPhase
|
||||||
|
const control_l1_fa = /** @type {LionField} */ (childAriaFixture.querySelector(
|
||||||
|
'[name=l1_fa]',
|
||||||
|
));
|
||||||
|
const control_l1_fb = /** @type {LionField} */ (childAriaFixture.querySelector(
|
||||||
|
'[name=l1_fb]',
|
||||||
|
));
|
||||||
|
const control_l2_fa = /** @type {LionField} */ (childAriaFixture.querySelector(
|
||||||
|
'[name=l2_fa]',
|
||||||
|
));
|
||||||
|
const control_l2_fb = /** @type {LionField} */ (childAriaFixture.querySelector(
|
||||||
|
'[name=l2_fb]',
|
||||||
|
));
|
||||||
|
|
||||||
|
// @ts-expect-error removeChild should always be inherited via LitElement?
|
||||||
|
control_l1_fa._parentFormGroup.removeChild(control_l1_fa);
|
||||||
|
await control_l1_fa.updateComplete;
|
||||||
|
// @ts-expect-error removeChild should always be inherited via LitElement?
|
||||||
|
control_l1_fb._parentFormGroup.removeChild(control_l1_fb);
|
||||||
|
await control_l1_fb.updateComplete;
|
||||||
|
// @ts-expect-error removeChild should always be inherited via LitElement?
|
||||||
|
control_l2_fa._parentFormGroup.removeChild(control_l2_fa);
|
||||||
|
await control_l2_fa.updateComplete;
|
||||||
|
// @ts-expect-error removeChild should always be inherited via LitElement?
|
||||||
|
control_l2_fb._parentFormGroup.removeChild(control_l2_fb);
|
||||||
|
await control_l2_fb.updateComplete;
|
||||||
|
|
||||||
|
// 'L1' fields (inside lion-fieldset[name="l1_g"]) should point to l1(group) msg
|
||||||
|
expect(input_l1_fa.getAttribute('aria-describedby')).to.not.contain(
|
||||||
|
msg_l1_g.id,
|
||||||
|
'l1 input(a) refers parent/group',
|
||||||
|
);
|
||||||
|
expect(input_l1_fb.getAttribute('aria-describedby')).to.not.contain(
|
||||||
|
msg_l1_g.id,
|
||||||
|
'l1 input(b) refers parent/group',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also check that aria-describedby of the inputs are not overridden (this relation was
|
||||||
|
// put there in lion-input(using lion-field)).
|
||||||
|
expect(input_l1_fa.getAttribute('aria-describedby')).to.contain(
|
||||||
|
msg_l1_fa.id,
|
||||||
|
'l1 input(a) refers local field',
|
||||||
|
);
|
||||||
|
expect(input_l1_fb.getAttribute('aria-describedby')).to.contain(
|
||||||
|
msg_l1_fb.id,
|
||||||
|
'l1 input(b) refers local field',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also make feedback element point to nested fieldset inputs
|
||||||
|
expect(input_l2_fa.getAttribute('aria-describedby')).to.not.contain(
|
||||||
|
msg_l1_g.id,
|
||||||
|
'l2 input(a) refers grandparent/group.group',
|
||||||
|
);
|
||||||
|
expect(input_l2_fb.getAttribute('aria-describedby')).to.not.contain(
|
||||||
|
msg_l1_g.id,
|
||||||
|
'l2 input(b) refers grandparent/group.group',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check cleanup of FormGroup on disconnect
|
||||||
|
const l2_g = /** @type {FormGroup} */ (childAriaFixture.querySelector('[name=l2_g]'));
|
||||||
|
expect(l2_g.__descriptionElementsInParentChain.size).to.not.equal(0);
|
||||||
|
// @ts-expect-error removeChild should always be inherited via LitElement?
|
||||||
|
l2_g._parentFormGroup.removeChild(l2_g);
|
||||||
|
await l2_g.updateComplete;
|
||||||
|
expect(l2_g.__descriptionElementsInParentChain.size).to.equal(0);
|
||||||
|
}
|
||||||
/* eslint-enable camelcase */
|
/* eslint-enable camelcase */
|
||||||
|
|
||||||
// 'L1' fields (inside lion-fieldset[name="l1_g"]) should point to l1(group) msg
|
|
||||||
expect(input_l1_fa.getAttribute('aria-describedby')).to.contain(
|
|
||||||
msg_l1_g.id,
|
|
||||||
'l1 input(a) refers parent/group',
|
|
||||||
);
|
|
||||||
expect(input_l1_fb.getAttribute('aria-describedby')).to.contain(
|
|
||||||
msg_l1_g.id,
|
|
||||||
'l1 input(b) refers parent/group',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also check that aria-describedby of the inputs are not overridden (this relation was
|
|
||||||
// put there in lion-input(using lion-field)).
|
|
||||||
expect(input_l1_fa.getAttribute('aria-describedby')).to.contain(
|
|
||||||
msg_l1_fa.id,
|
|
||||||
'l1 input(a) refers local field',
|
|
||||||
);
|
|
||||||
expect(input_l1_fb.getAttribute('aria-describedby')).to.contain(
|
|
||||||
msg_l1_fb.id,
|
|
||||||
'l1 input(b) refers local field',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Also make feedback element point to nested fieldset inputs
|
|
||||||
expect(input_l2_fa.getAttribute('aria-describedby')).to.contain(
|
|
||||||
msg_l1_g.id,
|
|
||||||
'l2 input(a) refers grandparent/group.group',
|
|
||||||
);
|
|
||||||
expect(input_l2_fb.getAttribute('aria-describedby')).to.contain(
|
|
||||||
msg_l1_g.id,
|
|
||||||
'l2 input(b) refers grandparent/group.group',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check order: the nearest ('dom wise': so 1. local, 2. parent, 3. grandparent) message
|
|
||||||
// should be read first by screen reader
|
|
||||||
const dA = /** @type {string} */ (input_l2_fa.getAttribute('aria-describedby'));
|
|
||||||
expect(
|
|
||||||
// @ts-expect-error
|
|
||||||
dA.indexOf(msg_l2_fa.id) < dA.indexOf(msg_l2_g.id) < dA.indexOf(msg_l1_g.id),
|
|
||||||
).to.equal(true, 'order of ids');
|
|
||||||
const dB = input_l2_fb.getAttribute('aria-describedby');
|
|
||||||
expect(
|
|
||||||
// @ts-expect-error
|
|
||||||
dB.indexOf(msg_l2_fb.id) < dB.indexOf(msg_l2_g.id) < dB.indexOf(msg_l1_g.id),
|
|
||||||
).to.equal(true, 'order of ids');
|
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
|
||||||
it(`reads feedback message belonging to fieldset when child input is focused
|
it(`reads feedback message belonging to fieldset when child input is focused
|
||||||
(via aria-describedby)`, async () => {
|
(via aria-describedby)`, async () => {
|
||||||
childAriaTest(await childAriaFixture('feedback'));
|
await childAriaTest(await childAriaFixture('feedback'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`reads help-text message belonging to fieldset when child input is focused
|
it(`reads help-text message belonging to fieldset when child input is focused
|
||||||
(via aria-describedby)`, async () => {
|
(via aria-describedby)`, async () => {
|
||||||
childAriaTest(await childAriaFixture('help-text'));
|
await childAriaTest(await childAriaFixture('help-text'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`cleans up feedback message belonging to fieldset on disconnect`, async () => {
|
||||||
|
const el = await childAriaFixture('feedback');
|
||||||
|
await childAriaTest(el, { cleanupPhase: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`cleans up help-text message belonging to fieldset on disconnect`, async () => {
|
||||||
|
const el = await childAriaFixture('help-text');
|
||||||
|
await childAriaTest(el, { cleanupPhase: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,35 +5,13 @@ import { FormControlMixin } from '../src/FormControlMixin.js';
|
||||||
import { FormRegistrarMixin } from '../src/registration/FormRegistrarMixin.js';
|
import { FormRegistrarMixin } from '../src/registration/FormRegistrarMixin.js';
|
||||||
|
|
||||||
describe('FormControlMixin', () => {
|
describe('FormControlMixin', () => {
|
||||||
const inputSlot = '<input slot="input" />';
|
const inputSlot = html`<input slot="input" />`;
|
||||||
|
|
||||||
class FormControlMixinClass extends FormControlMixin(LitElement) {}
|
class FormControlMixinClass extends FormControlMixin(LitElement) {}
|
||||||
|
|
||||||
const tagString = defineCE(FormControlMixinClass);
|
const tagString = defineCE(FormControlMixinClass);
|
||||||
const tag = unsafeStatic(tagString);
|
const tag = unsafeStatic(tagString);
|
||||||
|
|
||||||
it('has a label', async () => {
|
|
||||||
const elAttr = /** @type {FormControlMixinClass} */ (await fixture(html`
|
|
||||||
<${tag} label="Email address">${inputSlot}</${tag}>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(elAttr.label).to.equal('Email address', 'as an attribute');
|
|
||||||
|
|
||||||
const elProp = /** @type {FormControlMixinClass} */ (await fixture(html`
|
|
||||||
<${tag}
|
|
||||||
.label=${'Email address'}
|
|
||||||
>${inputSlot}
|
|
||||||
</${tag}>`));
|
|
||||||
expect(elProp.label).to.equal('Email address', 'as a property');
|
|
||||||
|
|
||||||
const elElem = /** @type {FormControlMixinClass} */ (await fixture(html`
|
|
||||||
<${tag}>
|
|
||||||
<label slot="label">Email address</label>
|
|
||||||
${inputSlot}
|
|
||||||
</${tag}>`));
|
|
||||||
expect(elElem.label).to.equal('Email address', 'as an element');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is hidden when attribute hidden is true', async () => {
|
it('is hidden when attribute hidden is true', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<${tag} hidden>
|
<${tag} hidden>
|
||||||
|
|
@ -43,172 +21,283 @@ describe('FormControlMixin', () => {
|
||||||
expect(el).not.to.be.displayed;
|
expect(el).not.to.be.displayed;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has a label that supports inner html', async () => {
|
describe('Label and helpText api', () => {
|
||||||
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
it('has a label', async () => {
|
||||||
<${tag}>
|
const elAttr = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
<label slot="label">Email <span>address</span></label>
|
<${tag} label="Email address">${inputSlot}</${tag}>
|
||||||
${inputSlot}
|
`));
|
||||||
</${tag}>`));
|
|
||||||
expect(el.label).to.equal('Email address');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only takes label of direct child', async () => {
|
expect(elAttr.label).to.equal('Email address', 'as an attribute');
|
||||||
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
|
||||||
<${tag}>
|
const elProp = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
<${tag} label="Email address">
|
<${tag}
|
||||||
|
.label=${'Email address'}
|
||||||
|
>${inputSlot}
|
||||||
|
</${tag}>`));
|
||||||
|
expect(elProp.label).to.equal('Email address', 'as a property');
|
||||||
|
|
||||||
|
const elElem = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
|
<${tag}>
|
||||||
|
<label slot="label">Email address</label>
|
||||||
${inputSlot}
|
${inputSlot}
|
||||||
</${tag}>
|
</${tag}>`));
|
||||||
</${tag}>`));
|
expect(elElem.label).to.equal('Email address', 'as an element');
|
||||||
expect(el.label).to.equal('');
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('can have a help-text', async () => {
|
it('has a label that supports inner html', async () => {
|
||||||
const elAttr = /** @type {FormControlMixinClass} */ (await fixture(html`
|
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
<${tag} help-text="We will not send you any spam">${inputSlot}</${tag}>
|
<${tag}>
|
||||||
`));
|
<label slot="label">Email <span>address</span></label>
|
||||||
expect(elAttr.helpText).to.equal('We will not send you any spam', 'as an attribute');
|
|
||||||
|
|
||||||
const elProp = /** @type {FormControlMixinClass} */ (await fixture(html`
|
|
||||||
<${tag}
|
|
||||||
.helpText=${'We will not send you any spam'}
|
|
||||||
>${inputSlot}
|
|
||||||
</${tag}>`));
|
|
||||||
expect(elProp.helpText).to.equal('We will not send you any spam', 'as a property');
|
|
||||||
|
|
||||||
const elElem = /** @type {FormControlMixinClass} */ (await fixture(html`
|
|
||||||
<${tag}>
|
|
||||||
<div slot="help-text">We will not send you any spam</div>
|
|
||||||
${inputSlot}
|
|
||||||
</${tag}>`));
|
|
||||||
expect(elElem.helpText).to.equal('We will not send you any spam', 'as an element');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can have a help-text that supports inner html', async () => {
|
|
||||||
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
|
||||||
<${tag}>
|
|
||||||
<div slot="help-text">We will not send you any <span>spam</span></div>
|
|
||||||
${inputSlot}
|
|
||||||
</${tag}>`));
|
|
||||||
expect(el.helpText).to.equal('We will not send you any spam');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only takes help-text of direct child', async () => {
|
|
||||||
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
|
||||||
<${tag}>
|
|
||||||
<${tag} help-text="We will not send you any spam">
|
|
||||||
${inputSlot}
|
${inputSlot}
|
||||||
</${tag}>
|
</${tag}>`));
|
||||||
</${tag}>`));
|
expect(el.label).to.equal('Email address');
|
||||||
expect(el.helpText).to.equal('');
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('does not duplicate aria-describedby and aria-labelledby ids', async () => {
|
it('only takes label of direct child', async () => {
|
||||||
const el = /** @type {FormControlMixinClass} */ (await fixture(`
|
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
<${tagString} help-text="This element will be disconnected/reconnected">${inputSlot}</${tagString}>
|
<${tag}>
|
||||||
`));
|
<${tag} label="Email address">
|
||||||
|
${inputSlot}
|
||||||
|
</${tag}>
|
||||||
|
</${tag}>`));
|
||||||
|
expect(el.label).to.equal('');
|
||||||
|
});
|
||||||
|
|
||||||
const wrapper = /** @type {LitElement} */ (await fixture(`<div></div>`));
|
it('can have a help-text', async () => {
|
||||||
el.parentElement?.appendChild(wrapper);
|
const elAttr = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
wrapper.appendChild(el);
|
<${tag} help-text="We will not send you any spam">${inputSlot}</${tag}>
|
||||||
await wrapper.updateComplete;
|
`));
|
||||||
|
expect(elAttr.helpText).to.equal('We will not send you any spam', 'as an attribute');
|
||||||
|
|
||||||
['aria-describedby', 'aria-labelledby'].forEach(ariaAttributeName => {
|
const elProp = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
const ariaAttribute = Array.from(el.children)
|
<${tag}
|
||||||
.find(child => child.slot === 'input')
|
.helpText=${'We will not send you any spam'}
|
||||||
?.getAttribute(ariaAttributeName)
|
>${inputSlot}
|
||||||
?.trim()
|
</${tag}>`));
|
||||||
.split(' ');
|
expect(elProp.helpText).to.equal('We will not send you any spam', 'as a property');
|
||||||
const hasDuplicate = !!ariaAttribute?.find((elm, i) => ariaAttribute.indexOf(elm) !== i);
|
|
||||||
expect(hasDuplicate).to.be.false;
|
const elElem = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
|
<${tag}>
|
||||||
|
<div slot="help-text">We will not send you any spam</div>
|
||||||
|
${inputSlot}
|
||||||
|
</${tag}>`));
|
||||||
|
expect(elElem.helpText).to.equal('We will not send you any spam', 'as an element');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can have a help-text that supports inner html', async () => {
|
||||||
|
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
|
<${tag}>
|
||||||
|
<div slot="help-text">We will not send you any <span>spam</span></div>
|
||||||
|
${inputSlot}
|
||||||
|
</${tag}>`));
|
||||||
|
expect(el.helpText).to.equal('We will not send you any spam');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only takes help-text of direct child', async () => {
|
||||||
|
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
|
<${tag}>
|
||||||
|
<${tag} help-text="We will not send you any spam">
|
||||||
|
${inputSlot}
|
||||||
|
</${tag}>
|
||||||
|
</${tag}>`));
|
||||||
|
expect(el.helpText).to.equal('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// FIXME: Broken test
|
describe('Accessibility', () => {
|
||||||
it.skip('internally sorts aria-describedby and aria-labelledby ids', async () => {
|
it('does not duplicate aria-describedby and aria-labelledby ids on reconnect', async () => {
|
||||||
const wrapper = await fixture(html`
|
const wrapper = /** @type {HTMLElement} */ (await fixture(html`
|
||||||
<div id="wrapper">
|
<div id="wrapper">
|
||||||
<div id="additionalLabelA">should go after input internals</div>
|
<${tag} help-text="This element will be disconnected/reconnected">${inputSlot}</${tag}>
|
||||||
<div id="additionalDescriptionA">should go after input internals</div>
|
</div>
|
||||||
|
`));
|
||||||
|
const el = /** @type {FormControlMixinClass} */ (wrapper.querySelector(tagString));
|
||||||
|
const labelIdsBefore = /** @type {string} */ (el._inputNode.getAttribute('aria-labelledby'));
|
||||||
|
const descriptionIdsBefore = /** @type {string} */ (el._inputNode.getAttribute(
|
||||||
|
'aria-describedby',
|
||||||
|
));
|
||||||
|
// Reconnect
|
||||||
|
wrapper.removeChild(el);
|
||||||
|
wrapper.appendChild(el);
|
||||||
|
const labelIdsAfter = /** @type {string} */ (el._inputNode.getAttribute('aria-labelledby'));
|
||||||
|
const descriptionIdsAfter = /** @type {string} */ (el._inputNode.getAttribute(
|
||||||
|
'aria-describedby',
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(labelIdsBefore).to.equal(labelIdsAfter);
|
||||||
|
expect(descriptionIdsBefore).to.equal(descriptionIdsAfter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds aria-live="polite" to the feedback slot', async () => {
|
||||||
|
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
<${tag}>
|
<${tag}>
|
||||||
<input slot="input" />
|
${inputSlot}
|
||||||
<label slot="label">Added to label by default</label>
|
<div slot="feedback">Added to see attributes</div>
|
||||||
<div slot="feedback">Added to description by default</div>
|
|
||||||
</${tag}>
|
</${tag}>
|
||||||
<div id="additionalLabelB">should go after input internals</div>
|
`));
|
||||||
<div id="additionalDescriptionB">should go after input internals</div>
|
|
||||||
</div>`);
|
|
||||||
const el = /** @type {FormControlMixinClass} */ (wrapper.querySelector(tagString));
|
|
||||||
const { _inputNode } = el;
|
|
||||||
|
|
||||||
// 1. addToAriaLabelledBy()
|
expect(
|
||||||
// external inputs should go in order defined by user
|
Array.from(el.children)
|
||||||
const labelA = /** @type {HTMLElement} */ (wrapper.querySelector('#additionalLabelA'));
|
.find(child => child.slot === 'feedback')
|
||||||
const labelB = /** @type {HTMLElement} */ (wrapper.querySelector('#additionalLabelB'));
|
?.getAttribute('aria-live'),
|
||||||
el.addToAriaLabelledBy(labelA);
|
).to.equal('polite');
|
||||||
el.addToAriaLabelledBy(labelB);
|
});
|
||||||
|
|
||||||
const ariaLabelId = /** @type {number} */ (_inputNode
|
it('clicking the label should call `_onLabelClick`', async () => {
|
||||||
.getAttribute('aria-labelledby')
|
const spy = sinon.spy();
|
||||||
?.indexOf(`label-${el._inputId}`));
|
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
||||||
|
<${tag} ._onLabelClick="${spy}">
|
||||||
|
${inputSlot}
|
||||||
|
</${tag}>
|
||||||
|
`));
|
||||||
|
expect(spy).to.not.have.been.called;
|
||||||
|
el._labelNode.click();
|
||||||
|
expect(spy).to.have.been.calledOnce;
|
||||||
|
});
|
||||||
|
|
||||||
const ariaLabelA = /** @type {number} */ (_inputNode
|
describe('Adding extra labels and descriptions', () => {
|
||||||
.getAttribute('aria-labelledby')
|
it(`supports centrally orchestrated labels/descriptions via addToAriaLabelledBy() /
|
||||||
?.indexOf('additionalLabelA'));
|
removeFromAriaLabelledBy() / addToAriaDescribedBy() / removeFromAriaDescribedBy()`, async () => {
|
||||||
|
const wrapper = /** @type {HTMLElement} */ (await fixture(html`
|
||||||
|
<div id="wrapper">
|
||||||
|
<${tag}>
|
||||||
|
${inputSlot}
|
||||||
|
<label slot="label">Added to label by default</label>
|
||||||
|
<div slot="feedback">Added to description by default</div>
|
||||||
|
</${tag}>
|
||||||
|
<div id="additionalLabel"> This also needs to be read whenever the input has focus</div>
|
||||||
|
<div id="additionalDescription"> Same for this </div>
|
||||||
|
</div>`));
|
||||||
|
const el = /** @type {FormControlMixinClass} */ (wrapper.querySelector(tagString));
|
||||||
|
// wait until the field element is done rendering
|
||||||
|
await el.updateComplete;
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
const ariaLabelB = /** @type {number} */ (_inputNode
|
// 1a. addToAriaLabelledBy()
|
||||||
.getAttribute('aria-labelledby')
|
// Check if the aria attr is filled initially
|
||||||
?.indexOf('additionalLabelB'));
|
expect(/** @type {string} */ (el._inputNode.getAttribute('aria-labelledby'))).to.contain(
|
||||||
|
`label-${el._inputId}`,
|
||||||
|
);
|
||||||
|
const additionalLabel = /** @type {HTMLElement} */ (wrapper.querySelector(
|
||||||
|
'#additionalLabel',
|
||||||
|
));
|
||||||
|
el.addToAriaLabelledBy(additionalLabel);
|
||||||
|
await el.updateComplete;
|
||||||
|
let labelledbyAttr = /** @type {string} */ (el._inputNode.getAttribute('aria-labelledby'));
|
||||||
|
// Now check if ids are added to the end (not overridden)
|
||||||
|
expect(labelledbyAttr).to.contain(`additionalLabel`);
|
||||||
|
// Should be placed in the end
|
||||||
|
expect(
|
||||||
|
labelledbyAttr.indexOf(`label-${el._inputId}`) <
|
||||||
|
labelledbyAttr.indexOf('additionalLabel'),
|
||||||
|
);
|
||||||
|
|
||||||
expect(ariaLabelId < ariaLabelB && ariaLabelB < ariaLabelA).to.be.true;
|
// 1b. removeFromAriaLabelledBy()
|
||||||
|
el.removeFromAriaLabelledBy(additionalLabel);
|
||||||
|
await el.updateComplete;
|
||||||
|
labelledbyAttr = /** @type {string} */ (el._inputNode.getAttribute('aria-labelledby'));
|
||||||
|
// Now check if ids are added to the end (not overridden)
|
||||||
|
expect(labelledbyAttr).to.not.contain(`additionalLabel`);
|
||||||
|
|
||||||
// 2. addToAriaDescribedBy()
|
// 2a. addToAriaDescribedBy()
|
||||||
// Check if the aria attr is filled initially
|
// Check if the aria attr is filled initially
|
||||||
const descA = /** @type {HTMLElement} */ (wrapper.querySelector('#additionalDescriptionA'));
|
expect(/** @type {string} */ (el._inputNode.getAttribute('aria-describedby'))).to.contain(
|
||||||
const descB = /** @type {HTMLElement} */ (wrapper.querySelector('#additionalDescriptionB'));
|
`feedback-${el._inputId}`,
|
||||||
el.addToAriaDescribedBy(descB);
|
);
|
||||||
el.addToAriaDescribedBy(descA);
|
});
|
||||||
|
|
||||||
const ariaDescId = /** @type {number} */ (_inputNode
|
it('sorts internal elements, and allows opt-out', async () => {
|
||||||
.getAttribute('aria-describedby')
|
const wrapper = await fixture(html`
|
||||||
?.indexOf(`feedback-${el._inputId}`));
|
<div id="wrapper">
|
||||||
|
<${tag}>
|
||||||
|
<input slot="input" id="myInput" />
|
||||||
|
<label slot="label" id="internalLabel">Added to label by default</label>
|
||||||
|
<div slot="help-text" id="internalDescription">
|
||||||
|
Added to description by default
|
||||||
|
</div>
|
||||||
|
</${tag}>
|
||||||
|
<div id="externalLabelB">should go after input internals</div>
|
||||||
|
<div id="externalDescriptionB">should go after input internals</div>
|
||||||
|
</div>`);
|
||||||
|
const el = /** @type {FormControlMixinClass} */ (wrapper.querySelector(tagString));
|
||||||
|
|
||||||
const ariaDescA = /** @type {number} */ (_inputNode
|
// N.B. in real life we would never add the input to aria-describedby or -labelledby,
|
||||||
.getAttribute('aria-describedby')
|
// but this example purely demonstrates dom order is respected.
|
||||||
?.indexOf('additionalDescriptionA'));
|
// A real life scenario would be for instance when
|
||||||
|
// a Field or FormGroup would be extended and an extra slot would be added in the template
|
||||||
|
const myInput = /** @type {HTMLElement} */ (wrapper.querySelector('#myInput'));
|
||||||
|
el.addToAriaLabelledBy(myInput);
|
||||||
|
await el.updateComplete;
|
||||||
|
el.addToAriaDescribedBy(myInput);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
const ariaDescB = /** @type {number} */ (_inputNode
|
expect(
|
||||||
.getAttribute('aria-describedby')
|
/** @type {string} */ (el._inputNode.getAttribute('aria-labelledby')).split(' '),
|
||||||
?.indexOf('additionalDescriptionB'));
|
).to.eql(['myInput', 'internalLabel']);
|
||||||
|
expect(
|
||||||
|
/** @type {string} */ (el._inputNode.getAttribute('aria-describedby')).split(' '),
|
||||||
|
).to.eql(['myInput', 'internalDescription']);
|
||||||
|
|
||||||
// Should be placed in the end
|
// cleanup
|
||||||
expect(ariaDescId < ariaDescB && ariaDescB < ariaDescA).to.be.true;
|
el.removeFromAriaLabelledBy(myInput);
|
||||||
});
|
await el.updateComplete;
|
||||||
|
el.removeFromAriaDescribedBy(myInput);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
it('adds aria-live="polite" to the feedback slot', async () => {
|
// opt-out of reorder
|
||||||
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
el.addToAriaLabelledBy(myInput, { reorder: false });
|
||||||
<${tag}>
|
await el.updateComplete;
|
||||||
${inputSlot}
|
el.addToAriaDescribedBy(myInput, { reorder: false });
|
||||||
<div slot="feedback">Added to see attributes</div>
|
await el.updateComplete;
|
||||||
</${tag}>
|
|
||||||
`));
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
Array.from(el.children)
|
/** @type {string} */ (el._inputNode.getAttribute('aria-labelledby')).split(' '),
|
||||||
.find(child => child.slot === 'feedback')
|
).to.eql(['internalLabel', 'myInput']);
|
||||||
?.getAttribute('aria-live'),
|
expect(
|
||||||
).to.equal('polite');
|
/** @type {string} */ (el._inputNode.getAttribute('aria-describedby')).split(' '),
|
||||||
});
|
).to.eql(['internalDescription', 'myInput']);
|
||||||
|
});
|
||||||
|
|
||||||
it('clicking the label should call `_onLabelClick`', async () => {
|
it('respects provided order for external elements', async () => {
|
||||||
const spy = sinon.spy();
|
const wrapper = await fixture(html`
|
||||||
const el = /** @type {FormControlMixinClass} */ (await fixture(html`
|
<div id="wrapper">
|
||||||
<${tag} ._onLabelClick="${spy}">
|
<div id="externalLabelA">should go after input internals</div>
|
||||||
${inputSlot}
|
<div id="externalDescriptionA">should go after input internals</div>
|
||||||
</${tag}>
|
<${tag}>
|
||||||
`));
|
<input slot="input" />
|
||||||
expect(spy).to.not.have.been.called;
|
<label slot="label" id="internalLabel">Added to label by default</label>
|
||||||
el._labelNode.click();
|
<div slot="help-text" id="internalDescription">Added to description by default</div>
|
||||||
expect(spy).to.have.been.calledOnce;
|
</${tag}>
|
||||||
|
<div id="externalLabelB">should go after input internals</div>
|
||||||
|
<div id="externalDescriptionB">should go after input internals</div>
|
||||||
|
</div>`);
|
||||||
|
const el = /** @type {FormControlMixinClass} */ (wrapper.querySelector(tagString));
|
||||||
|
|
||||||
|
// 1. addToAriaLabelledBy()
|
||||||
|
const labelA = /** @type {HTMLElement} */ (wrapper.querySelector('#externalLabelA'));
|
||||||
|
const labelB = /** @type {HTMLElement} */ (wrapper.querySelector('#externalLabelB'));
|
||||||
|
// external inputs should go in order defined by user
|
||||||
|
el.addToAriaLabelledBy(labelA);
|
||||||
|
el.addToAriaLabelledBy(labelB);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
/** @type {string} */ (el._inputNode.getAttribute('aria-labelledby')).split(' '),
|
||||||
|
).to.eql(['internalLabel', 'externalLabelA', 'externalLabelB']);
|
||||||
|
|
||||||
|
// 2. addToAriaDescribedBy()
|
||||||
|
const descrA = /** @type {HTMLElement} */ (wrapper.querySelector('#externalDescriptionA'));
|
||||||
|
const descrB = /** @type {HTMLElement} */ (wrapper.querySelector('#externalDescriptionB'));
|
||||||
|
|
||||||
|
el.addToAriaDescribedBy(descrA);
|
||||||
|
el.addToAriaDescribedBy(descrB);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
/** @type {string} */ (el._inputNode.getAttribute('aria-describedby')).split(' '),
|
||||||
|
).to.eql(['internalDescription', 'externalDescriptionA', 'externalDescriptionB']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Model-value-changed event propagation', () => {
|
describe('Model-value-changed event propagation', () => {
|
||||||
|
|
|
||||||
|
|
@ -193,59 +193,6 @@ describe('<lion-field>', () => {
|
||||||
`prefix-${el._inputId} suffix-${el._inputId}`,
|
`prefix-${el._inputId} suffix-${el._inputId}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Move test below to FormControlMixin.test.js.
|
|
||||||
it(`allows to add to aria description or label via addToAriaLabelledBy() and
|
|
||||||
addToAriaDescribedBy()`, async () => {
|
|
||||||
const wrapper = /** @type {HTMLElement} */ (await fixture(html`
|
|
||||||
<div id="wrapper">
|
|
||||||
<${tag}>
|
|
||||||
${inputSlot}
|
|
||||||
<label slot="label">Added to label by default</label>
|
|
||||||
<div slot="feedback">Added to description by default</div>
|
|
||||||
</${tag}>
|
|
||||||
<div id="additionalLabel"> This also needs to be read whenever the input has focus</div>
|
|
||||||
<div id="additionalDescription"> Same for this </div>
|
|
||||||
</div>`));
|
|
||||||
const el = /** @type {LionField} */ (wrapper.querySelector(tagString));
|
|
||||||
// wait until the field element is done rendering
|
|
||||||
await el.updateComplete;
|
|
||||||
await el.updateComplete;
|
|
||||||
|
|
||||||
const { _inputNode } = el;
|
|
||||||
|
|
||||||
// 1. addToAriaLabel()
|
|
||||||
// Check if the aria attr is filled initially
|
|
||||||
expect(_inputNode.getAttribute('aria-labelledby')).to.contain(`label-${el._inputId}`);
|
|
||||||
const additionalLabel = /** @type {HTMLElement} */ (wrapper.querySelector(
|
|
||||||
'#additionalLabel',
|
|
||||||
));
|
|
||||||
el.addToAriaLabelledBy(additionalLabel);
|
|
||||||
const labelledbyAttr = /** @type {string} */ (_inputNode.getAttribute('aria-labelledby'));
|
|
||||||
// Now check if ids are added to the end (not overridden)
|
|
||||||
expect(labelledbyAttr).to.contain(`label-${el._inputId}`);
|
|
||||||
// Should be placed in the end
|
|
||||||
expect(
|
|
||||||
labelledbyAttr.indexOf(`label-${el._inputId}`) < labelledbyAttr.indexOf('additionalLabel'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. addToAriaDescription()
|
|
||||||
// Check if the aria attr is filled initially
|
|
||||||
expect(_inputNode.getAttribute('aria-describedby')).to.contain(`feedback-${el._inputId}`);
|
|
||||||
const additionalDescription = /** @type {HTMLElement} */ (wrapper.querySelector(
|
|
||||||
'#additionalDescription',
|
|
||||||
));
|
|
||||||
el.addToAriaDescribedBy(additionalDescription);
|
|
||||||
const describedbyAttr = /** @type {string} */ (_inputNode.getAttribute('aria-describedby'));
|
|
||||||
|
|
||||||
// Now check if ids are added to the end (not overridden)
|
|
||||||
expect(describedbyAttr).to.contain(`feedback-${el._inputId}`);
|
|
||||||
// Should be placed in the end
|
|
||||||
expect(
|
|
||||||
describedbyAttr.indexOf(`feedback-${el._inputId}`) <
|
|
||||||
describedbyAttr.indexOf('additionalDescription'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(`Validation`, () => {
|
describe(`Validation`, () => {
|
||||||
|
|
|
||||||
|
|
@ -163,12 +163,26 @@ export declare class FormControlHost {
|
||||||
reorder?: boolean | undefined;
|
reorder?: boolean | undefined;
|
||||||
},
|
},
|
||||||
): void;
|
): void;
|
||||||
|
public removeFromAriaLabelledBy(
|
||||||
|
element: HTMLElement,
|
||||||
|
customConfig?: {
|
||||||
|
reorder?: boolean | undefined;
|
||||||
|
},
|
||||||
|
): void;
|
||||||
|
public removeFromAriaDescribedBy(
|
||||||
|
element: HTMLElement,
|
||||||
|
customConfig?: {
|
||||||
|
reorder?: boolean | undefined;
|
||||||
|
},
|
||||||
|
): void;
|
||||||
__reorderAriaDescribedNodes: boolean | undefined;
|
__reorderAriaDescribedNodes: boolean | undefined;
|
||||||
__getDirectSlotChild(slotName: string): HTMLElement;
|
__getDirectSlotChild(slotName: string): HTMLElement;
|
||||||
__dispatchInitialModelValueChangedEvent(): void;
|
__dispatchInitialModelValueChangedEvent(): void;
|
||||||
__repropagateChildrenInitialized: boolean | undefined;
|
__repropagateChildrenInitialized: boolean | undefined;
|
||||||
protected _onBeforeRepropagateChildrenValues(ev: CustomEvent): void;
|
protected _onBeforeRepropagateChildrenValues(ev: CustomEvent): void;
|
||||||
__repropagateChildrenValues(ev: CustomEvent): void;
|
__repropagateChildrenValues(ev: CustomEvent): void;
|
||||||
|
_parentFormGroup: FormControlHost;
|
||||||
|
_repropagationCondition(target: FormControlHost): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare function FormControlImplementation<T extends Constructor<LitElement>>(
|
export declare function FormControlImplementation<T extends Constructor<LitElement>>(
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export declare class ChoiceGroupHost {
|
||||||
__delegateNameAttribute(child: FormControlHost): void;
|
__delegateNameAttribute(child: FormControlHost): void;
|
||||||
|
|
||||||
protected _onBeforeRepropagateChildrenValues(ev: Event): void;
|
protected _onBeforeRepropagateChildrenValues(ev: Event): void;
|
||||||
|
__oldModelValue: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare function ChoiceGroupImplementation<T extends Constructor<LitElement>>(
|
export declare function ChoiceGroupImplementation<T extends Constructor<LitElement>>(
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export declare class FormGroupHost {
|
||||||
_setValueForAllFormElements(property: string, value: any): void;
|
_setValueForAllFormElements(property: string, value: any): void;
|
||||||
resetInteractionState(): void;
|
resetInteractionState(): void;
|
||||||
clearGroup(): void;
|
clearGroup(): void;
|
||||||
|
__descriptionElementsInParentChain: Set<HTMLElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare function FormGroupImplementation<T extends Constructor<LitElement>>(
|
export declare function FormGroupImplementation<T extends Constructor<LitElement>>(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue