I’m writing a custom <select>
element using native DOM (no Polymer).
I’m trying to use my element with a <label>
element and correctly trigger click events to my element when the <label>
is clicked, i.e.:
<label>
My Select:
<my-select placeholder="Please select one">...</my-select>
</label>
or
<label for='mySelect1'>My Select:</label>
<my-select id='mySelect1' placeholder="Please select one">...</my-select>
However, this behavior doesn’t seem to work out of the box, even if I add a tabindex
to make it focusable.
Here’s a stripped down version of the code and a JSFiddle with some basic debugging:
var MySelectOptionProto = Object.create(HTMLElement.prototype);
document.registerElement('my-select-option', {
prototype: MySelectOptionProto
});
var MySelectProto = Object.create(HTMLElement.prototype);
MySelectProto.createdCallback = function() {
if (!this.getAttribute('tabindex')) {
this.setAttribute('tabindex', 0);
}
this.placeholder = document.createElement('span');
this.placeholder.className = 'my-select-placeholder';
this.appendChild(this.placeholder);
var selected = this.querySelector('my-select-option[selected]');
this.placeholder.textContent = selected
? selected.textContent
: (this.getAttribute('placeholder') || '');
};
document.registerElement('my-select', {
prototype: MySelectProto
});
I’m writing a custom <select>
element using native DOM (no Polymer).
I’m trying to use my element with a <label>
element and correctly trigger click events to my element when the <label>
is clicked, i.e.:
<label>
My Select:
<my-select placeholder="Please select one">...</my-select>
</label>
or
<label for='mySelect1'>My Select:</label>
<my-select id='mySelect1' placeholder="Please select one">...</my-select>
However, this behavior doesn’t seem to work out of the box, even if I add a tabindex
to make it focusable.
Here’s a stripped down version of the code and a JSFiddle with some basic debugging:
var MySelectOptionProto = Object.create(HTMLElement.prototype);
document.registerElement('my-select-option', {
prototype: MySelectOptionProto
});
var MySelectProto = Object.create(HTMLElement.prototype);
MySelectProto.createdCallback = function() {
if (!this.getAttribute('tabindex')) {
this.setAttribute('tabindex', 0);
}
this.placeholder = document.createElement('span');
this.placeholder.className = 'my-select-placeholder';
this.appendChild(this.placeholder);
var selected = this.querySelector('my-select-option[selected]');
this.placeholder.textContent = selected
? selected.textContent
: (this.getAttribute('placeholder') || '');
};
document.registerElement('my-select', {
prototype: MySelectProto
});
Share
Improve this question
edited Oct 21, 2022 at 0:32
Sebastian Simon
19.6k8 gold badges61 silver badges84 bronze badges
asked Jul 12, 2016 at 4:52
DogokuDogoku
4,6754 gold badges26 silver badges35 bronze badges
2 Answers
Reset to default 8But the standard is not finished, and maybe in the future you’ll be able to use the built-in semantic with autonomous custom elements, too.
— Supersharp, Jul 15, 2016 at 16:05
We live in the future now. The standard has significantly changed and we have more capable custom elements now.
Tl;dr:
Labels work iff the constructor associated with your custom element has a formAssociated
property with the value true
.
When looking at the documentation, at first sight, you might conclude that custom elements are not allowed as <label>
targets.
The docs say:
Elements that can be associated with a
<label>
element include<button>
,<input>
(except fortype="hidden"
),<meter>
,<output>
,<progress>
,<select>
and<textarea>
.
But custom elements are not mentioned. You try your luck anyway and try to enter this HTML fragment in an HTML validator:
<label>Label: <my-custom-element></my-custom-element></label>
or:
<label for="my-id">Label: </label><my-custom-element id="my-id"></my-custom-element>
and both of these are valid HTML snippets!
However, trying this HTML fragment:
<label for="my-id">Label: </label><span id="my-id"></span>
shows this error message:
Error: The value of the
for
attribute of thelabel
element must be the ID of a non-hidden form control.
But why is a random <my-custom-element>
considered a “non-hidden form control”?
In the validator’s GitHub issues you then find the already fixed issue #963:
label for
doesn’t allow custom elements as targetsThe spec allows form-associated custom elements as
for
targets
https://html.spec.whatwg/multipage/forms.html#attr-label-forThis currently triggers an error:
<label for="mat-select-0">Select:</label> <mat-select role="listbox" id="mat-select-0"></mat-select>
Easy to check if element name contains a dash. Much harder to check that custom element definition includes a
static formAssociated
property returningtrue
, since this is likely buried deep in an external framework.js
file:
https://html.spec.whatwg/multipage/custom-elements.html#custom-elements-face-example
There is indeed one peculiar addendum in the WHATWG specification which enumerates the same “labelable elements” as the documentation plus form-associated custom elements. There is an example in the specification, and researching this problem today yields this Q&A post in the results as well as blog articles such as “More capable form controls” by Arthur Evans (archived link), which show what the solution is.
The solution to enable <label>
clicking targeting custom elements: formAssociated
All that needs to be done to enable the <label>
action is to add a formAssociated
property with the value true
to the constructor of the custom element.
However, the code in the question needs to be ported to the current Web Components API.
The new formAssociated
property is part of the new ElementInternals API (see attachInternals
) which is used for richer form element control and accessibility.
Nowadays, custom elements are defined with classes
and customElements.define
, and ShadowRoots are used.
The static formAssociated = true;
field can then be added to the class which you consider to belong to the “form-associated custom elements”.
Having this property makes your custom element dispatch click
events when clicking the <label>
; now we’re getting somewhere!
In order to turn this click
into a focus
action, there’s one more thing we need in the attachShadow
call: the property delegatesFocus
set to true
.
// Rough translation of your original code to modern code, minus the `tabIndex` experimentation.
class MySelectOptionProto extends HTMLElement{}
customElements.define("my-select-option", MySelectOptionProto);
class MySelectProto extends HTMLElement{
#shadowRoot;
#internals = this.attachInternals();
static formAssociated = true;
constructor(){
super();
this.#shadowRoot = this.attachShadow({
mode: "open",
delegatesFocus: true
});
this.#shadowRoot.replaceChildren(Object.assign(document.createElement("span"), {
classList: "my-select-placeholder"
}));
this.#shadowRoot.firstElementChild.textContent = this.querySelector("my-select-option[selected]")?.textContent ?? (this.getAttribute("placeholder") || "");
}
}
customElements.define("my-select", MySelectProto);
<label>My label:
<my-select placeholder="Hello">
<my-select-option>Goodbye</my-select-option>
<my-select-option>world</my-select-option>
</my-select>
</label>
<label for="my-id">My label:</label>
<my-select id="my-id">
<my-select-option>Never gonna give you</my-select-option>
<my-select-option selected>up</my-select-option>
</my-select>
<label for="my-id">My other label</label>
delegatesFocus
will focus the first focusable child of the shadow root, when some non-focusable part of the custom element has been clicked.
If you need more control, you can remove the property, and add an event listener to the custom element instance.
In order to be sure that the click came from the <label>
, you can check if the event’s target
is your custom element, i.e. this
, and that the element at the x and y position on the screen was a <label>
targeting your custom element.
Fortunately, this can be done quite reliably by passing x
and y
to document.elementFromPoint
.
Then, simply calling focus
on your desired element will do the job.
The list of labels that target a custom element is another thing that the ElementInternals API provides via the labels
property.
class MySelectProto extends HTMLElement{
// …
constructor(){
// …
this.addEventListener("click", ({ target, x, y }) => {
const relatedTarget = document.elementFromPoint(x, y);
if(target === this && new Set(this.#internals.labels).has(relatedTarget)){
console.log("Label", relatedTarget, "has been clicked and", this, "can now bee focused.");
// this.focus(); // Or, more likely, some target within `this.#shadowRoot`.
}
});
}
}
Only the phrasing content elements can be targeted by <label>
.
So you'll have to manage the focus action by yourself if you want to use a non-standard (autonomous custom) element.
Instead you can choose to define a Customized built-in element that will extend the <select>
element, as in the following example:
https://jsfiddle/h56692ee/4/
var MySelectProto = Object.create( HTMLSelectElement.prototype )
//...
document.registerElement('my-select', { prototype: MySelectProto, extends: "select" } )
You'll need to use the is
attribute notation for HTML:
<label>
My Select:
<select is="my-select" placeholder="Please select one">
<option>...</option>
</select>
</label>
Update More explanations in these 2 posts: here and there.