feat(field): order aria attributes based on nodes
This commit is contained in:
parent
49304a9c86
commit
95d553e239
7 changed files with 192 additions and 165 deletions
|
|
@ -1,5 +1,16 @@
|
||||||
import { html, css, nothing, dedupeMixin, SlotMixin } from '@lion/core';
|
import { html, css, nothing, dedupeMixin, SlotMixin } from '@lion/core';
|
||||||
import { FormRegisteringMixin } from './FormRegisteringMixin.js';
|
import { FormRegisteringMixin } from './FormRegisteringMixin.js';
|
||||||
|
import { getAriaElementsInRightDomOrder } from './utils/getAriaElementsInRightDomOrder.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates random unique identifier (for dom elements)
|
||||||
|
* @param {string} prefix
|
||||||
|
*/
|
||||||
|
function uuid(prefix) {
|
||||||
|
return `${prefix}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.substr(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #FormControlMixin :
|
* #FormControlMixin :
|
||||||
|
|
@ -17,26 +28,10 @@ export const FormControlMixin = dedupeMixin(
|
||||||
class FormControlMixin extends FormRegisteringMixin(SlotMixin(superclass)) {
|
class FormControlMixin extends FormRegisteringMixin(SlotMixin(superclass)) {
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
/**
|
|
||||||
* A list of ids that will be put on the _inputNode as a serialized string
|
|
||||||
*/
|
|
||||||
_ariaDescribedby: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A list of ids that will be put on the _inputNode as a serialized string
|
|
||||||
*/
|
|
||||||
_ariaLabelledby: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When no light dom defined and prop set
|
* When no light dom defined and prop set
|
||||||
*/
|
*/
|
||||||
label: {
|
label: String,
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When no light dom defined and prop set
|
* When no light dom defined and prop set
|
||||||
|
|
@ -45,6 +40,16 @@ export const FormControlMixin = dedupeMixin(
|
||||||
type: String,
|
type: String,
|
||||||
attribute: 'help-text',
|
attribute: 'help-text',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contains all elements that should end up in aria-labelledby of `._inputNode`
|
||||||
|
*/
|
||||||
|
_ariaLabelledNodes: Array,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contains all elements that should end up in aria-describedby of `._inputNode`
|
||||||
|
*/
|
||||||
|
_ariaDescribedNodes: Array,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,12 +72,20 @@ export const FormControlMixin = dedupeMixin(
|
||||||
updated(changedProps) {
|
updated(changedProps) {
|
||||||
super.updated(changedProps);
|
super.updated(changedProps);
|
||||||
|
|
||||||
if (changedProps.has('_ariaLabelledby')) {
|
if (changedProps.has('_ariaLabelledNodes')) {
|
||||||
this._onAriaLabelledbyChanged({ _ariaLabelledby: this._ariaLabelledby });
|
this.__reflectAriaAttr(
|
||||||
|
'aria-labelledby',
|
||||||
|
this._ariaLabelledNodes,
|
||||||
|
this.__reorderAriaLabelledNodes,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedProps.has('_ariaDescribedby')) {
|
if (changedProps.has('_ariaDescribedNodes')) {
|
||||||
this._onAriaDescribedbyChanged({ _ariaDescribedby: this._ariaDescribedby });
|
this.__reflectAriaAttr(
|
||||||
|
'aria-describedby',
|
||||||
|
this._ariaDescribedNodes,
|
||||||
|
this.__reorderAriaDescribedNodes,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (changedProps.has('label')) {
|
if (changedProps.has('label')) {
|
||||||
|
|
@ -102,11 +115,9 @@ export const FormControlMixin = dedupeMixin(
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this._inputId = `${this.localName}-${Math.random()
|
this._inputId = uuid(this.localName);
|
||||||
.toString(36)
|
this._ariaLabelledNodes = [];
|
||||||
.substr(2, 10)}`;
|
this._ariaDescribedNodes = [];
|
||||||
this._ariaLabelledby = '';
|
|
||||||
this._ariaDescribedby = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -118,7 +129,6 @@ export const FormControlMixin = dedupeMixin(
|
||||||
/**
|
/**
|
||||||
* Public methods
|
* Public methods
|
||||||
*/
|
*/
|
||||||
|
|
||||||
_enhanceLightDomClasses() {
|
_enhanceLightDomClasses() {
|
||||||
if (this._inputNode) {
|
if (this._inputNode) {
|
||||||
this._inputNode.classList.add('form-control');
|
this._inputNode.classList.add('form-control');
|
||||||
|
|
@ -133,26 +143,14 @@ export const FormControlMixin = dedupeMixin(
|
||||||
}
|
}
|
||||||
if (_labelNode) {
|
if (_labelNode) {
|
||||||
_labelNode.setAttribute('for', this._inputId);
|
_labelNode.setAttribute('for', this._inputId);
|
||||||
_labelNode.id = _labelNode.id || `label-${this._inputId}`;
|
this.addToAriaLabelledBy(_labelNode, { idPrefix: 'label' });
|
||||||
const labelledById = ` ${_labelNode.id}`;
|
|
||||||
if (this._ariaLabelledby.indexOf(labelledById) === -1) {
|
|
||||||
this._ariaLabelledby += ` ${_labelNode.id}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (_helpTextNode) {
|
if (_helpTextNode) {
|
||||||
_helpTextNode.id = _helpTextNode.id || `help-text-${this._inputId}`;
|
this.addToAriaDescribedBy(_helpTextNode, { idPrefix: 'help-text' });
|
||||||
const describeIdHelpText = ` ${_helpTextNode.id}`;
|
|
||||||
if (this._ariaDescribedby.indexOf(describeIdHelpText) === -1) {
|
|
||||||
this._ariaDescribedby += ` ${_helpTextNode.id}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (_feedbackNode) {
|
if (_feedbackNode) {
|
||||||
_feedbackNode.setAttribute('aria-live', 'polite');
|
_feedbackNode.setAttribute('aria-live', 'polite');
|
||||||
_feedbackNode.id = _feedbackNode.id || `feedback-${this._inputId}`;
|
this.addToAriaDescribedBy(_feedbackNode, { idPrefix: 'feedback' });
|
||||||
const describeIdFeedback = ` ${_feedbackNode.id}`;
|
|
||||||
if (this._ariaDescribedby.indexOf(describeIdFeedback) === -1) {
|
|
||||||
this._ariaDescribedby += ` ${_feedbackNode.id}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this._enhanceLightDomA11yForAdditionalSlots();
|
this._enhanceLightDomA11yForAdditionalSlots();
|
||||||
}
|
}
|
||||||
|
|
@ -169,37 +167,30 @@ export const FormControlMixin = dedupeMixin(
|
||||||
additionalSlots.forEach(additionalSlot => {
|
additionalSlots.forEach(additionalSlot => {
|
||||||
const element = this.__getDirectSlotChild(additionalSlot);
|
const element = this.__getDirectSlotChild(additionalSlot);
|
||||||
if (element) {
|
if (element) {
|
||||||
element.id = element.id || `${additionalSlot}-${this._inputId}`;
|
|
||||||
if (element.hasAttribute('data-label') === true) {
|
if (element.hasAttribute('data-label') === true) {
|
||||||
this._ariaLabelledby += ` ${element.id}`;
|
this.addToAriaLabelledBy(element, { idPrefix: additionalSlot });
|
||||||
}
|
}
|
||||||
if (element.hasAttribute('data-description') === true) {
|
if (element.hasAttribute('data-description') === true) {
|
||||||
this._ariaDescribedby += ` ${element.id}`;
|
this.addToAriaDescribedBy(element, { idPrefix: additionalSlot });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Will handle label, prefix/suffix/before/after (if they contain data-label flag attr).
|
|
||||||
* Also, contents of id references that will be put in the <lion-field>._ariaLabelledby property
|
|
||||||
* from an external context, will be read by a screen reader.
|
|
||||||
*/
|
|
||||||
_onAriaLabelledbyChanged({ _ariaLabelledby }) {
|
|
||||||
if (this._inputNode) {
|
|
||||||
this._inputNode.setAttribute('aria-labelledby', _ariaLabelledby);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Will handle help text, validation feedback and character counter,
|
* Will handle help text, validation feedback and character counter,
|
||||||
* prefix/suffix/before/after (if they contain data-description flag attr).
|
* prefix/suffix/before/after (if they contain data-description flag attr).
|
||||||
* Also, contents of id references that will be put in the <lion-field>._ariaDescribedby property
|
* Also, contents of id references that will be put in the <lion-field>._ariaDescribedby property
|
||||||
* from an external context, will be read by a screen reader.
|
* from an external context, will be read by a screen reader.
|
||||||
*/
|
*/
|
||||||
_onAriaDescribedbyChanged({ _ariaDescribedby }) {
|
__reflectAriaAttr(attrName, nodes, reorder) {
|
||||||
if (this._inputNode) {
|
if (this._inputNode) {
|
||||||
this._inputNode.setAttribute('aria-describedby', _ariaDescribedby);
|
if (reorder) {
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
nodes = getAriaElementsInRightDomOrder(nodes);
|
||||||
|
}
|
||||||
|
const string = nodes.map(n => n.id).join(' ');
|
||||||
|
this._inputNode.setAttribute(attrName, string);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -464,36 +455,6 @@ export const FormControlMixin = dedupeMixin(
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// aria-labelledby and aria-describedby helpers
|
|
||||||
// TODO: consider extracting to generic ariaLabel helper mixin
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Let the order of adding ids to aria element by DOM order, so that the screen reader
|
|
||||||
* respects visual order when reading:
|
|
||||||
* https://developers.google.com/web/fundamentals/accessibility/focus/dom-order-matters
|
|
||||||
* @param {array} descriptionElements - holds references to description or label elements whose
|
|
||||||
* id should be returned
|
|
||||||
* @returns {array} sorted set of elements based on dom order
|
|
||||||
*
|
|
||||||
* TODO: make this method part of a more generic mixin or util and also use for lion-field
|
|
||||||
*/
|
|
||||||
static _getAriaElementsInRightDomOrder(descriptionElements) {
|
|
||||||
const putPrecedingSiblingsAndLocalParentsFirst = (a, b) => {
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
|
|
||||||
const pos = a.compareDocumentPosition(b);
|
|
||||||
if (
|
|
||||||
pos === Node.DOCUMENT_POSITION_PRECEDING ||
|
|
||||||
pos === Node.DOCUMENT_POSITION_CONTAINED_BY
|
|
||||||
) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
};
|
|
||||||
|
|
||||||
const descriptionEls = descriptionElements.filter(el => el); // filter out null references
|
|
||||||
return descriptionEls.sort(putPrecedingSiblingsAndLocalParentsFirst);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns dom references to all elements that should be referred to by field(s)
|
// Returns dom references to all elements that should be referred to by field(s)
|
||||||
_getAriaDescriptionElements() {
|
_getAriaDescriptionElements() {
|
||||||
return [this._helpTextNode, this._feedbackNode];
|
return [this._helpTextNode, this._feedbackNode];
|
||||||
|
|
@ -501,22 +462,30 @@ export const FormControlMixin = dedupeMixin(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meant for Application Developers wanting to add to aria-labelledby attribute.
|
* Meant for Application Developers wanting to add to aria-labelledby attribute.
|
||||||
* @param {string} id - should be the id of an element that contains the label for the
|
* @param {Element} element
|
||||||
* concerned field or fieldset, living in the same shadow root as the host element of field or
|
|
||||||
* fieldset.
|
|
||||||
*/
|
*/
|
||||||
addToAriaLabel(id) {
|
addToAriaLabelledBy(element, { idPrefix, reorder } = { reorder: true }) {
|
||||||
this._ariaLabelledby += ` ${id}`;
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
element.id = element.id || `${idPrefix}-${this._inputId}`;
|
||||||
|
if (!this._ariaLabelledNodes.includes(element)) {
|
||||||
|
this._ariaLabelledNodes = [...this._ariaLabelledNodes, element];
|
||||||
|
// This value will be read when we need to reflect to attr
|
||||||
|
this.__reorderAriaLabelledNodes = Boolean(reorder);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meant for Application Developers wanting to add to aria-describedby attribute.
|
* Meant for Application Developers wanting to add to aria-describedby attribute.
|
||||||
* @param {string} id - should be the id of an element that contains the label for the
|
* @param {Element} element
|
||||||
* concerned field or fieldset, living in the same shadow root as the host element of field or
|
|
||||||
* fieldset.
|
|
||||||
*/
|
*/
|
||||||
addToAriaDescription(id) {
|
addToAriaDescribedBy(element, { idPrefix, reorder } = { reorder: true }) {
|
||||||
this._ariaDescribedby += ` ${id}`;
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
element.id = element.id || `${idPrefix}-${this._inputId}`;
|
||||||
|
if (!this._ariaDescribedNodes.includes(element)) {
|
||||||
|
this._ariaDescribedNodes = [...this._ariaDescribedNodes, element];
|
||||||
|
// This value will be read when we need to reflect to attr
|
||||||
|
this.__reorderAriaDescribedNodes = Boolean(reorder);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
__getDirectSlotChild(slotName) {
|
__getDirectSlotChild(slotName) {
|
||||||
|
|
|
||||||
26
packages/field/src/utils/getAriaElementsInRightDomOrder.js
Normal file
26
packages/field/src/utils/getAriaElementsInRightDomOrder.js
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
/**
|
||||||
|
* @desc Let the order of adding ids to aria element by DOM order, so that the screen reader
|
||||||
|
* respects visual order when reading:
|
||||||
|
* https://developers.google.com/web/fundamentals/accessibility/focus/dom-order-matters
|
||||||
|
* @param {array} descriptionElements - holds references to description or label elements whose
|
||||||
|
* id should be returned
|
||||||
|
* @returns {array} sorted set of elements based on dom order
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export function getAriaElementsInRightDomOrder(descriptionElements, { reverse } = {}) {
|
||||||
|
const putPrecedingSiblingsAndLocalParentsFirst = (a, b) => {
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
|
||||||
|
const pos = a.compareDocumentPosition(b);
|
||||||
|
if (pos === Node.DOCUMENT_POSITION_PRECEDING || pos === Node.DOCUMENT_POSITION_CONTAINED_BY) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const descriptionEls = descriptionElements.filter(el => el); // filter out null references
|
||||||
|
descriptionEls.sort(putPrecedingSiblingsAndLocalParentsFirst);
|
||||||
|
if (reverse) {
|
||||||
|
descriptionEls.reverse();
|
||||||
|
}
|
||||||
|
return descriptionEls;
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,6 @@ import { localizeTearDown } from '@lion/localize/test-helpers.js';
|
||||||
|
|
||||||
import '../lion-field.js';
|
import '../lion-field.js';
|
||||||
|
|
||||||
const nameSuffix = '';
|
|
||||||
const tagString = 'lion-field';
|
const tagString = 'lion-field';
|
||||||
const tag = unsafeStatic(tagString);
|
const tag = unsafeStatic(tagString);
|
||||||
const inputSlotString = '<input slot="input" />';
|
const inputSlotString = '<input slot="input" />';
|
||||||
|
|
@ -197,7 +196,7 @@ describe('<lion-field>', () => {
|
||||||
expect(disabledel._inputNode.hasAttribute('disabled')).to.equal(true);
|
expect(disabledel._inputNode.hasAttribute('disabled')).to.equal(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(`A11y${nameSuffix}`, () => {
|
describe('Accessibility', () => {
|
||||||
it(`by setting corresponding aria-labelledby (for label) and aria-describedby (for helpText, feedback)
|
it(`by setting corresponding aria-labelledby (for label) and aria-describedby (for helpText, feedback)
|
||||||
~~~
|
~~~
|
||||||
<lion-field>
|
<lion-field>
|
||||||
|
|
@ -221,9 +220,9 @@ describe('<lion-field>', () => {
|
||||||
`);
|
`);
|
||||||
const nativeInput = Array.from(el.children).find(child => child.slot === 'input');
|
const nativeInput = Array.from(el.children).find(child => child.slot === 'input');
|
||||||
|
|
||||||
expect(nativeInput.getAttribute('aria-labelledby')).to.equal(` label-${el._inputId}`);
|
expect(nativeInput.getAttribute('aria-labelledby')).to.equal(`label-${el._inputId}`);
|
||||||
expect(nativeInput.getAttribute('aria-describedby')).to.contain(` help-text-${el._inputId}`);
|
expect(nativeInput.getAttribute('aria-describedby')).to.contain(`help-text-${el._inputId}`);
|
||||||
expect(nativeInput.getAttribute('aria-describedby')).to.contain(` feedback-${el._inputId}`);
|
expect(nativeInput.getAttribute('aria-describedby')).to.contain(`feedback-${el._inputId}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`allows additional slots (prefix, suffix, before, after) to be included in labelledby
|
it(`allows additional slots (prefix, suffix, before, after) to be included in labelledby
|
||||||
|
|
@ -239,16 +238,16 @@ describe('<lion-field>', () => {
|
||||||
|
|
||||||
const nativeInput = Array.from(el.children).find(child => child.slot === 'input');
|
const nativeInput = Array.from(el.children).find(child => child.slot === 'input');
|
||||||
expect(nativeInput.getAttribute('aria-labelledby')).to.contain(
|
expect(nativeInput.getAttribute('aria-labelledby')).to.contain(
|
||||||
` before-${el._inputId} after-${el._inputId}`,
|
`before-${el._inputId} after-${el._inputId}`,
|
||||||
);
|
);
|
||||||
expect(nativeInput.getAttribute('aria-describedby')).to.contain(
|
expect(nativeInput.getAttribute('aria-describedby')).to.contain(
|
||||||
` prefix-${el._inputId} suffix-${el._inputId}`,
|
`prefix-${el._inputId} suffix-${el._inputId}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: put this test on FormControlMixin test once there
|
// TODO: put this test on FormControlMixin test once there
|
||||||
it(`allows to add to aria description or label via addToAriaLabel() and
|
it(`allows to add to aria description or label via addToAriaLabelledBy() and
|
||||||
addToAriaDescription()`, async () => {
|
addToAriaDescribedBy()`, async () => {
|
||||||
const wrapper = await fixture(html`
|
const wrapper = await fixture(html`
|
||||||
<div id="wrapper">
|
<div id="wrapper">
|
||||||
<${tag}>
|
<${tag}>
|
||||||
|
|
@ -269,7 +268,7 @@ describe('<lion-field>', () => {
|
||||||
// 1. addToAriaLabel()
|
// 1. addToAriaLabel()
|
||||||
// Check if the aria attr is filled initially
|
// Check if the aria attr is filled initially
|
||||||
expect(_inputNode.getAttribute('aria-labelledby')).to.contain(`label-${el._inputId}`);
|
expect(_inputNode.getAttribute('aria-labelledby')).to.contain(`label-${el._inputId}`);
|
||||||
el.addToAriaLabel('additionalLabel');
|
el.addToAriaLabelledBy(wrapper.querySelector('#additionalLabel'));
|
||||||
// Now check if ids are added to the end (not overridden)
|
// Now check if ids are added to the end (not overridden)
|
||||||
expect(_inputNode.getAttribute('aria-labelledby')).to.contain(`label-${el._inputId}`);
|
expect(_inputNode.getAttribute('aria-labelledby')).to.contain(`label-${el._inputId}`);
|
||||||
// Should be placed in the end
|
// Should be placed in the end
|
||||||
|
|
@ -281,7 +280,7 @@ describe('<lion-field>', () => {
|
||||||
// 2. addToAriaDescription()
|
// 2. addToAriaDescription()
|
||||||
// Check if the aria attr is filled initially
|
// Check if the aria attr is filled initially
|
||||||
expect(_inputNode.getAttribute('aria-describedby')).to.contain(`feedback-${el._inputId}`);
|
expect(_inputNode.getAttribute('aria-describedby')).to.contain(`feedback-${el._inputId}`);
|
||||||
el.addToAriaDescription('additionalDescription');
|
el.addToAriaDescribedBy(wrapper.querySelector('#additionalDescription'));
|
||||||
// Now check if ids are added to the end (not overridden)
|
// Now check if ids are added to the end (not overridden)
|
||||||
expect(_inputNode.getAttribute('aria-describedby')).to.contain(`feedback-${el._inputId}`);
|
expect(_inputNode.getAttribute('aria-describedby')).to.contain(`feedback-${el._inputId}`);
|
||||||
// Should be placed in the end
|
// Should be placed in the end
|
||||||
|
|
@ -292,7 +291,7 @@ describe('<lion-field>', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(`Validation${nameSuffix}`, () => {
|
describe(`Validation`, () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset and preload validation translations
|
// Reset and preload validation translations
|
||||||
localizeTearDown();
|
localizeTearDown();
|
||||||
|
|
@ -414,7 +413,7 @@ describe('<lion-field>', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(`Content projection${nameSuffix}`, () => {
|
describe(`Content projection`, () => {
|
||||||
it('renders correctly all slot elements in light DOM', async () => {
|
it('renders correctly all slot elements in light DOM', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<${tag}>
|
<${tag}>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { expect, fixture, html } from '@open-wc/testing';
|
||||||
|
import { getAriaElementsInRightDomOrder } from '../../src/utils/getAriaElementsInRightDomOrder.js';
|
||||||
|
|
||||||
|
describe('getAriaElementsInRightDomOrder', () => {
|
||||||
|
it('orders by putting preceding siblings and local parents first', async () => {
|
||||||
|
const el = await fixture(html`
|
||||||
|
<div>
|
||||||
|
<div id="a"></div>
|
||||||
|
<div></div>
|
||||||
|
<div id="b">
|
||||||
|
<div></div>
|
||||||
|
<div id="b-child"></div>
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
<div id="c"></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
const [a, _1, b, _2, bChild, _3, c, _4] = el.querySelectorAll('div');
|
||||||
|
const unorderedNodes = [bChild, c, a, b];
|
||||||
|
const result = getAriaElementsInRightDomOrder(unorderedNodes);
|
||||||
|
expect(result).to.eql([a, b, bChild, c]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can order reversely', async () => {
|
||||||
|
const el = await fixture(html`
|
||||||
|
<div>
|
||||||
|
<div id="a"></div>
|
||||||
|
<div></div>
|
||||||
|
<div id="b">
|
||||||
|
<div></div>
|
||||||
|
<div id="b-child"></div>
|
||||||
|
</div>
|
||||||
|
<div></div>
|
||||||
|
<div id="c"></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
const [a, _1, b, _2, bChild, _3, c, _4] = el.querySelectorAll('div');
|
||||||
|
const unorderedNodes = [bChild, c, a, b];
|
||||||
|
const result = getAriaElementsInRightDomOrder(unorderedNodes, { reverse: true });
|
||||||
|
expect(result).to.eql([c, bChild, b, a]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,7 @@ import { SlotMixin, html, LitElement } from '@lion/core';
|
||||||
import { DisabledMixin } from '@lion/core/src/DisabledMixin.js';
|
import { DisabledMixin } from '@lion/core/src/DisabledMixin.js';
|
||||||
import { ValidateMixin } from '@lion/validate';
|
import { ValidateMixin } from '@lion/validate';
|
||||||
import { FormControlMixin, FormRegistrarMixin } from '@lion/field';
|
import { FormControlMixin, FormRegistrarMixin } from '@lion/field';
|
||||||
|
import { getAriaElementsInRightDomOrder } from '@lion/field/src/utils/getAriaElementsInRightDomOrder.js';
|
||||||
import { FormElementsHaveNoError } from './FormElementsHaveNoError.js';
|
import { FormElementsHaveNoError } from './FormElementsHaveNoError.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -427,15 +428,10 @@ export class LionFieldset extends FormRegistrarMixin(
|
||||||
* @param {array} descriptionElements - description elements like feedback and help-text
|
* @param {array} descriptionElements - description elements like feedback and help-text
|
||||||
*/
|
*/
|
||||||
static _addDescriptionElementIdsToField(field, descriptionElements) {
|
static _addDescriptionElementIdsToField(field, descriptionElements) {
|
||||||
// TODO: make clear in documentation that help-text and feedback slot should be appended by now
|
const orderedEls = getAriaElementsInRightDomOrder(descriptionElements, { reverse: true });
|
||||||
// and dynamically appending (or dom-ifs etc) doesn't work
|
|
||||||
// TODO: we can cache this on constructor level for perf, but changing template via providers
|
|
||||||
// might go wrong then when dom order changes per instance. Although we could check if
|
|
||||||
// 'provision' has taken place or not
|
|
||||||
const orderedEls = this._getAriaElementsInRightDomOrder(descriptionElements);
|
|
||||||
orderedEls.forEach(el => {
|
orderedEls.forEach(el => {
|
||||||
if (field.addToAriaDescription) {
|
if (field.addToAriaDescribedBy) {
|
||||||
field.addToAriaDescription(el.id);
|
field.addToAriaDescribedBy(el, { reorder: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -884,11 +884,7 @@ describe('<lion-fieldset>', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('a11y', () => {
|
describe('Accessibility', () => {
|
||||||
// beforeEach(() => {
|
|
||||||
// localizeTearDown();
|
|
||||||
// });
|
|
||||||
|
|
||||||
it('has role="group" set', async () => {
|
it('has role="group" set', async () => {
|
||||||
const fieldset = await fixture(html`<${tag}>${inputSlots}</${tag}>`);
|
const fieldset = await fixture(html`<${tag}>${inputSlots}</${tag}>`);
|
||||||
await nextFrame();
|
await nextFrame();
|
||||||
|
|
@ -985,48 +981,46 @@ describe('<lion-fieldset>', () => {
|
||||||
|
|
||||||
/* eslint-enable camelcase */
|
/* eslint-enable camelcase */
|
||||||
|
|
||||||
const ariaDescribedBy = el => el.getAttribute('aria-describedby');
|
|
||||||
|
|
||||||
// 'L1' fields (inside lion-fieldset[name="l1_g"]) should point to l1(group) msg
|
// 'L1' fields (inside lion-fieldset[name="l1_g"]) should point to l1(group) msg
|
||||||
expect(ariaDescribedBy(input_l1_fa)).to.contain(
|
expect(input_l1_fa.getAttribute('aria-describedby')).to.contain(
|
||||||
msg_l1_g.id,
|
msg_l1_g.id,
|
||||||
'l1 input(a) refers parent/group',
|
'l1 input(a) refers parent/group',
|
||||||
);
|
);
|
||||||
expect(ariaDescribedBy(input_l1_fb)).to.contain(
|
expect(input_l1_fb.getAttribute('aria-describedby')).to.contain(
|
||||||
msg_l1_g.id,
|
msg_l1_g.id,
|
||||||
'l1 input(b) refers parent/group',
|
'l1 input(b) refers parent/group',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Also check that aria-describedby of the inputs are not overridden (this relation was
|
// Also check that aria-describedby of the inputs are not overridden (this relation was
|
||||||
// put there in lion-input(using lion-field)).
|
// put there in lion-input(using lion-field)).
|
||||||
expect(ariaDescribedBy(input_l1_fa)).to.contain(
|
expect(input_l1_fa.getAttribute('aria-describedby')).to.contain(
|
||||||
msg_l1_fa.id,
|
msg_l1_fa.id,
|
||||||
'l1 input(a) refers local field',
|
'l1 input(a) refers local field',
|
||||||
);
|
);
|
||||||
expect(ariaDescribedBy(input_l1_fb)).to.contain(
|
expect(input_l1_fb.getAttribute('aria-describedby')).to.contain(
|
||||||
msg_l1_fb.id,
|
msg_l1_fb.id,
|
||||||
'l1 input(b) refers local field',
|
'l1 input(b) refers local field',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Also make feedback element point to nested fieldset inputs
|
// Also make feedback element point to nested fieldset inputs
|
||||||
expect(ariaDescribedBy(input_l2_fa)).to.contain(
|
expect(input_l2_fa.getAttribute('aria-describedby')).to.contain(
|
||||||
msg_l1_g.id,
|
msg_l1_g.id,
|
||||||
'l2 input(a) refers grandparent/group.group',
|
'l2 input(a) refers grandparent/group.group',
|
||||||
);
|
);
|
||||||
expect(ariaDescribedBy(input_l2_fb)).to.contain(
|
expect(input_l2_fb.getAttribute('aria-describedby')).to.contain(
|
||||||
msg_l1_g.id,
|
msg_l1_g.id,
|
||||||
'l2 input(b) refers grandparent/group.group',
|
'l2 input(b) refers grandparent/group.group',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check order: the nearest ('dom wise': so 1. local, 2. parent, 3. grandparent) message
|
// Check order: the nearest ('dom wise': so 1. local, 2. parent, 3. grandparent) message
|
||||||
// should be read first by screen reader
|
// should be read first by screen reader
|
||||||
let d = ariaDescribedBy(input_l2_fa);
|
const dA = input_l2_fa.getAttribute('aria-describedby');
|
||||||
expect(
|
expect(
|
||||||
d.indexOf(msg_l1_g.id) < d.indexOf(msg_l2_g.id) < d.indexOf(msg_l2_fa.id),
|
dA.indexOf(msg_l2_fa.id) < dA.indexOf(msg_l2_g.id) < dA.indexOf(msg_l1_g.id),
|
||||||
).to.equal(true, 'order of ids');
|
).to.equal(true, 'order of ids');
|
||||||
d = ariaDescribedBy(input_l2_fb);
|
const dB = input_l2_fb.getAttribute('aria-describedby');
|
||||||
expect(
|
expect(
|
||||||
d.indexOf(msg_l1_g.id) < d.indexOf(msg_l2_g.id) < d.indexOf(msg_l2_fb.id),
|
dB.indexOf(msg_l2_fb.id) < dB.indexOf(msg_l2_g.id) < dB.indexOf(msg_l1_g.id),
|
||||||
).to.equal(true, 'order of ids');
|
).to.equal(true, 'order of ids');
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,9 @@ export class LionSelectRich extends OverlayMixin(
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
static _isPrefilled(modelValue) {
|
static _isPrefilled(modelValue) {
|
||||||
if (!modelValue) {
|
if (!modelValue) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -202,6 +205,13 @@ export class LionSelectRich extends OverlayMixin(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get _inputNode() {
|
||||||
|
// In FormControl, we get direct child [slot="input"]. This doesn't work, because the overlay
|
||||||
|
// system wraps it in [slot="_overlay-shadow-outlet"]
|
||||||
|
// TODO: find a way to solve this by putting the wrapping part in shadow dom...
|
||||||
|
return this.querySelector('[slot="input"]');
|
||||||
|
}
|
||||||
|
|
||||||
updated(changedProps) {
|
updated(changedProps) {
|
||||||
super.updated(changedProps);
|
super.updated(changedProps);
|
||||||
|
|
||||||
|
|
@ -214,6 +224,22 @@ export class LionSelectRich extends OverlayMixin(
|
||||||
this.__retractRequestOptionsToBeDisabled();
|
this.__retractRequestOptionsToBeDisabled();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this._inputNode && this._invokerNode) {
|
||||||
|
if (changedProps.has('_ariaLabelledNodes')) {
|
||||||
|
this._invokerNode.setAttribute(
|
||||||
|
'aria-labelledby',
|
||||||
|
`${this._inputNode.getAttribute('aria-labelledby')} ${this._invokerNode.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedProps.has('_ariaDescribedNodes')) {
|
||||||
|
this._invokerNode.setAttribute(
|
||||||
|
'aria-describedby',
|
||||||
|
this._inputNode.getAttribute('aria-describedby'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
|
|
@ -272,35 +298,6 @@ export class LionSelectRich extends OverlayMixin(
|
||||||
return this.formElements.map(e => e[property]);
|
return this.formElements.map(e => e[property]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* add same aria-label to invokerNode as _inputNode
|
|
||||||
* @override
|
|
||||||
*/
|
|
||||||
_onAriaLabelledbyChanged({ _ariaLabelledby }) {
|
|
||||||
if (this._inputNode) {
|
|
||||||
this._inputNode.setAttribute('aria-labelledby', _ariaLabelledby);
|
|
||||||
}
|
|
||||||
if (this._invokerNode) {
|
|
||||||
this._invokerNode.setAttribute(
|
|
||||||
'aria-labelledby',
|
|
||||||
`${_ariaLabelledby} ${this._invokerNode.id}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* add same aria-label to invokerNode as _inputNode
|
|
||||||
* @override
|
|
||||||
*/
|
|
||||||
_onAriaDescribedbyChanged({ _ariaDescribedby }) {
|
|
||||||
if (this._inputNode) {
|
|
||||||
this._inputNode.setAttribute('aria-describedby', _ariaDescribedby);
|
|
||||||
}
|
|
||||||
if (this._invokerNode) {
|
|
||||||
this._invokerNode.setAttribute('aria-describedby', _ariaDescribedby);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
__setupEventListeners() {
|
__setupEventListeners() {
|
||||||
this.__onChildActiveChanged = this.__onChildActiveChanged.bind(this);
|
this.__onChildActiveChanged = this.__onChildActiveChanged.bind(this);
|
||||||
this.__onChildModelValueChanged = this.__onChildModelValueChanged.bind(this);
|
this.__onChildModelValueChanged = this.__onChildModelValueChanged.bind(this);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue