Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions lib/commons/text/label-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,15 @@ function labelText(virtualNode, context = {}) {

let labels;
if (implicitLabel) {
labels = [...explicitLabels, implicitLabel.actualNode];
labels.sort(nodeSorter);
// Avoid duplicates when the implicit label also has a `for`
// attribute pointing at this element (making it both implicit
// and explicit).
if (explicitLabels.includes(implicitLabel.actualNode)) {
labels = explicitLabels;
} else {
labels = [...explicitLabels, implicitLabel.actualNode];
labels.sort(nodeSorter);
}
} else {
labels = explicitLabels;
}
Expand All @@ -46,7 +53,7 @@ function labelText(virtualNode, context = {}) {
* Find a non-ARIA label for an element
* @private
* @param {VirtualNode} element The VirtualNode instance whose label we are seeking
* @return {HTMLElement} The label element, or null if none is found
* @return {HTMLElement[]} The label elements, or an empty array if none are found
*/
function getExplicitLabels(virtualNode) {
if (!virtualNode.attr('id')) {
Expand Down
38 changes: 37 additions & 1 deletion lib/commons/text/native-text-alternative.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,43 @@ export default function nativeTextAlternative(virtualNode, context = {}) {
*/
function findTextMethods(virtualNode) {
const elmSpec = getElementSpec(virtualNode, { noMatchAccessibleName: true });
const methods = elmSpec.namingMethods || [];
const methods = [...(elmSpec.namingMethods || [])];

// Form-associated custom elements can be labelled just like native
// form controls. When the element is form-associated and labelText
// is not already a naming method, add it so the accessible name
// algorithm can find the associated labels.
if (
!methods.includes('labelText') &&
isFormAssociatedCustomElement(virtualNode)
) {
methods.push('labelText');
}

return methods.map(methodName => nativeTextMethods[methodName]);
}

/**
* Check if a virtual node is a form-associated custom element.
* @private
* @param {VirtualNode} virtualNode
* @return {Boolean}
*/
function isFormAssociatedCustomElement(virtualNode) {
const { actualNode } = virtualNode;
if (!actualNode) {
return false;
}

// Custom element names must contain a hyphen
const { nodeName } = virtualNode.props;
if (!nodeName.includes('-')) {
return false;
}

// Check if the constructor declares `static formAssociated = true`.
// Note: the `labels` property is only on the ElementInternals object,
// not on the element itself, so we cannot use `actualNode.labels` here.
const ctor = window.customElements.get(nodeName);
return !!ctor?.formAssociated;
}
84 changes: 84 additions & 0 deletions test/commons/text/label-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,4 +117,88 @@ describe('text.labelText', function () {
assert.equal(labelText(target, { inLabelledByContext: true }), '');
});
});

describe('form-associated custom elements', function () {
var uniqueId = 0;

function defineFormAssociatedElement() {
var name = 'x-label-text-' + uniqueId++;
if (!customElements.get(name)) {
customElements.define(
name,
class extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals_ = this.attachInternals();
}
}
);
}
return name;
}

it('returns label text from an explicit label via for attribute', function () {
var tagName = defineFormAssociatedElement();
var target = queryFixture(
'<label for="target">Custom label</label>' +
'<' +
tagName +
' id="target"></' +
tagName +
'>'
);
assert.equal(labelText(target), 'Custom label');
});

it('returns label text from multiple explicit labels', function () {
var tagName = defineFormAssociatedElement();
var target = queryFixture(
'<label for="target">Label 1</label>' +
'<label for="target">Label 2</label>' +
'<' +
tagName +
' id="target"></' +
tagName +
'>'
);
assert.equal(labelText(target), 'Label 1 Label 2');
});

it('returns label text from an implicit label', function () {
var tagName = defineFormAssociatedElement();
var target = queryFixture(
'<label>Implicit label' +
'<' +
tagName +
' id="target"></' +
tagName +
'>' +
'</label>'
);
assert.equal(labelText(target), 'Implicit label');
});

it('does not duplicate when element is inside its explicit label', function () {
var tagName = defineFormAssociatedElement();
var target = queryFixture(
'<label for="target">Wrapping label' +
'<' +
tagName +
' id="target"></' +
tagName +
'>' +
'</label>'
);
assert.equal(labelText(target), 'Wrapping label');
});

it('returns empty string when no labels are associated', function () {
var tagName = defineFormAssociatedElement();
var target = queryFixture(
'<' + tagName + ' id="target"></' + tagName + '>'
);
assert.equal(labelText(target), '');
});
});
});
65 changes: 65 additions & 0 deletions test/commons/text/native-text-alternative.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,69 @@ describe('text.nativeTextAlternative', function () {
var vNode = queryFixture('<img id="target" alt="foo" role="none" />');
assert.equal(nativeTextAlternative(vNode), '');
});

describe('form-associated custom elements', function () {
var uniqueId = 0;

function defineFormAssociatedElement() {
var name = 'x-native-text-' + uniqueId++;
if (!customElements.get(name)) {
customElements.define(
name,
class extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals_ = this.attachInternals();
}
}
);
}
return name;
}

it('returns label text for a form-associated custom element with an explicit label', function () {
var tagName = defineFormAssociatedElement();
var vNode = queryFixture(
'<label for="target">Custom label</label>' +
'<' +
tagName +
' id="target"></' +
tagName +
'>'
);
assert.equal(nativeTextAlternative(vNode), 'Custom label');
});

it('returns `` for a custom element without formAssociated', function () {
var name = 'x-native-text-nfa-' + uniqueId++;
if (!customElements.get(name)) {
customElements.define(
name,
class extends HTMLElement {
constructor() {
super();
}
}
);
}
var vNode = queryFixture(
'<label for="target">Should not match</label>' +
'<' +
name +
' id="target"></' +
name +
'>'
);
assert.equal(nativeTextAlternative(vNode), '');
});

it('returns `` for a form-associated custom element without labels', function () {
var tagName = defineFormAssociatedElement();
var vNode = queryFixture(
'<' + tagName + ' id="target"></' + tagName + '>'
);
assert.equal(nativeTextAlternative(vNode), '');
});
});
});