It does seem like I enjoy a good <slot></slot>
. I mean, look, I wrote about them all the way back in 2018 in <slot/>
ing in Some Tips, and then in 2020, I spoke about Stacked Slots at a virtual Web Components SF meetup (see the associated slides), before sharing a proof of concept for Light DOM as Model. And, as if that weren't enough, here we are again, and I'm writing to you, friend, about <slot>
s. Today, we're going to get out of the theoretical and into the practical as we start on the path towards actual usage of Stacked Slots that I'm excited to bring to life as part of Adobe's Spectrum Web Components to support the delivery of Spectrum design's Help Text pattern.
Help text
Spectrumโs help text pattern:
provides either an informative description or an error message that gives more context about what a user needs to input. Itโs commonly used in forms.
Clearly, help text isnโt much use on its own. There needs to be some way to associate help text with the element it describes. Traditionally, that might look like:
<input aria-describedby="help-text" />
<div id="help-text">
The above input is described by this help text.
</div>
However, we are gathered here today to celebrate the awesomeness that are <slot>
s, so we have to be talking about shadow DOM (required to leverage browser native <slot>
elements and their various capabilities), and are likely talking about custom elements (because they all get along so well). With these two APIs together, it's not uncommon to see a form element writ into a custom element as follows:
<custom-form-element></custom-form-element>
From here, you're likely to see many APIs structured in a "component as function with properties" pattern that is common in various javascript frameworks and surface help-text
as an attribute of the <custom-form-element>
:
<custom-form-element
help-text="An input inside of this element is described by this help text."
></custom-form-element>
You might then see such an API expanded for help text across various states:
<custom-form-element
help-text="An input inside of this element is described by this help text."
help-text-invalid="An input inside of this element is described by this help text when invalid."
></custom-form-element>
Possibly, ad infinitum:
<custom-form-element
help-text="An input inside of this element is described by this help text."
help-text-valid="An input inside of this element is described by this help text when valid."
help-text-invalid="An input inside of this element is described by this help text when invalid."
help-text-when-you-appear-stuck="An input inside of this element is described by this help text when you appear stuck."
help-text-etc="An input inside of this element is described by this help text, etc."
></custom-form-element>
This API had only just started to talk about the <custom-form-element>
and already it is quite thick (imagine it with actual API for customizing the associated form element). This could certainly weigh on your consuming developers. What's more, while this is going to look (and work) great when javascript is on, or look great (though possibly not work) when delivered in a browser with Declarative Shadow DOM, it has no chance of looking good with neither (unless generally not seeing something counts as it looking good, which is true... sometimes), and it certainly doesn't use any <slot>
s!
To be clear, none of the notes above are inherently bad. Each of these could align with the desired philosophy of an element, a library of elements, or an application that leverages elements. I call them out here as a way to get to the point I'd like to make about <slot>
s. If your use case doesn't direct you towards an HTML-like API for your custom elements, more power to you. However, supplying this content as HTML allows us to:
- customize the DOM element that wraps your help text content
- deliver DOM in that content (e.g. anchor tags, icons, etc.)
- encapsulate any default functionality or styles belonging to help text content
- separate the concerns of delivering a form element from those of delivering help text content
- style help text content directly from the outside
To support all these things, I'd like to propose the following API.
<custom-form-element>
<custom-help-text slot="help-text">
An input inside of this element is described by this help text.
</custom-help-text>
<custom-help-text slot="negative-help-text">
An input inside of this element is described by this help text when invalid.
</custom-help-text>
</custom-form-element>
The above makes the presence and the customization of content beyond the initial help message easy to manage regardless of the context from which you're delivering it. You've got a custom form element; you slot in the default and negative help text messages and automatically they are correctly associated with the appropriate element within the parent's shadow DOM and hidden/shown based on the validity of the said parent. Similarly, you can slot in a single piece of help text, and it can be fully controlled from the JS scope in which is delivered:
<custom-form-element>
<custom-help-text slot="help-text">
An input inside of this element is described by this help text.
</custom-help-text>
</custom-form-element>
Let's look at how we can bring all of this into being.
<slot/>ing in some help text
For this last example, a single slot named help-text
is all you need to get started. Here's what that could look like in a no dependency custom element where we're taking nothing else into account except slotting in this one piece of content:
const template = document.createElement("template");
template.innerHTML = /*html*/`
<input />
<slot name="help-text"></slot>
`;
class CustomFormElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}
customElements.define("custom-form-element", CustomFormElement);
If this already has you excited to code, hop on over to webcomponents.dev and fork the project from this point.
What we've got here is relatively simple to start, so we've got lots of growing to do. Before we dive into supporting the help-text
AND negative-help-text
slots, where we can really dig into the concept of Stacked Slots, let's be sure that the content in this slot can be appropriately associated with the <input />
element that we're basing our <custom-form-element>
around for the time being.
All form controls need to be supplied with a label (something we are actively omitting at this time) to be accessible. Labels can be associated with a form control as follows:
- the form control can supply this content itself via the
aria-label
attribute - a secondary element can be referenced by ID in the form control's
aria-labelledby
attribute, or - the form control itself can be referenced by ID in a
<label>
element'sfor
attribute
Content associated in this way will be read as part of the primary description of the form control. Help text content doesn't require this level of priority in the element's description, so we will associate it by leveraging the form control's aria-describedby
attribute to reference this content by ID.
When referencing content by ID, it is important to remember that the elements on either side of the reference need to share a DOM tree for the reference can be completed. With our form control (the <input />
) being within our shadow root and our <custom-help-text>
element being slotted from the outside we can't simply add an ID on one and point to it from the other. Instead, we'll place our <slot>
element into a <div>
itself and give that <div>
the ID to reference from our form control. In this way, the <div>
can adopt the help text content projected onto the <slot>
element and make it available to the form control to reference via ID with its aria-describedby
attribute.
template.innerHTML = /*html*/`
<input aria-describedby="help-text" />
<div id="help-text">
<slot name="help-text"></slot>
</div>
`;
See it in living color, fork it, share it, and when you're done, come right back, so we can dig even deeper.
Now for the superpowers
That's right, custom elements allow you to give your HTML superpowers. A consumer of our element could already take what we've made, leverage the bubbling and composed events out of the <input />
element, and manage the text that is supplied to the <custom-help-text>
element from the outside, and we'll see what that looks like shortly. However, we can save most consumers a lot of work by baking some basic management directly into our custom element. This is exactly what is outlined in the code example above:
<custom-form-element>
<custom-help-text slot="help-text">
Describe interests you would like to explore.
</custom-help-text>
<custom-help-text slot="negative-help-text">
Enter at least one interest.
</custom-help-text>
</custom-form-element>
While there is much that can be done about managing when something deserves "negative help text", for our demo we'll only track whether our <input />
has content or not. To do so, and rather naively at that, we'll add the required
attribute. This will allow us to leverage the checkValidity()
method on the <input />
element to confirm whether any content has been supplied.
To support this, we'll expand our constructor
to bind a listener for the input
event to our host element:
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.appendChild(template.content.cloneNode(true));
this.addEventListener('input', this.handleInput);
}
Adding the listener to this
, even though the input
occurs within our shadow DOM, is useful to ensure that the handleInput()
callback is bound to our host element, and not the child <input />
. In our example, we'll be keeping the held state to an absolute minimum, but having this
reference the host element not only allows us to directly query selectors within our element, were we to hold state on our element we'd have access to that as well. In the handleInput()
callback we can examine the event
's composedPath()
to reacquire the <input />
element and call checkValidity()
on it:
handleInput(event) {
// ...
const target = event.composedPath()[0];
const showNegative = !target.checkValidity();
// ...
}
We'll leverage the showNegative
variable to tell our element to show the negative help text. showNegative
as a variable name is very specific to our help text use case, and that shouldn't be an issue with it scoped to our handleInput()
method. In larger scopes of work, we should look at making this attribute more semantic to our <custom-form-element>
. This might be managing it as invalid
, or similar, that would make sense to surface via the element's public API.
While we're here, we will also surface a getter/setter pair to manage the value
of our <input />
from the outside:
get value() {
return this.shadowRoot.querySelector('input').value;
}
set value(value) {
this.shadowRoot.querySelector('input').value = value;
}
Next we'll get into what we actually do with these values.
All the slots
So far, we've discussed the consumption of two different slots: help-text
and negative-help-text
. Why?
Well, some consumers want all the control. In this use case, we surface the help-text
slot so that absolutely anything can be put into it, whenever the parent application would like. Want to cheer your visitor on for every keystroke they make, this is the slot for that.
<custom-form-element
oninput="
const modiferElement = this.querySelector('#modifier');
const countElement = this.querySelector('#count');
const multipleElement = this.querySelector('#multiple');
const length = this.value.length;
multipleElement.textContent = length === 1
? ''
: 's';
countElement.textContent = length;
let modiferText = '';
if (length > 10) {
modiferText = 'Wow!';
} else if (length > 5) {
modiferText = 'Nice.';
} else if (length > 0) {
modiferText = 'Keep going.';
}
modiferElement.textContent = modiferText;
"
>
<custom-help-text slot="help-text">
<span id="modifier"></span> You've typed <span id="count">0</span> character<span id="multiple">s</span>.
</custom-help-text>
</custom-form-element>
Really, with the help-text
slot, access to the form control's value, and experience with which to interact with it from the outside, there's no end to how you could leverage the content you might supply. However, in more cases than not, swapping between "this is what you should do" and "this is how you get out of the problem you've gotten yourself in" text will likely support the goals of our consumers. In some cases, we might just be turning on the "this is how you get out of the problem you've gotten yourself in" text. Pairing the help-text
slot with a negative-help-text
slot, we can make the process of doing these things something they'll almost never have to think about.
So, where are we actually inserting these slots into our element's shadow DOM? We might start by positioning them next to each other, as siblings:
template.innerHTML = /*html*/`
<input aria-describedby="help-text" />
<div id="help-text">
<slot name="help-text"></slot>
<slot name="negative-help-text"></slot>
</div>
`;
From here we could leverage the showNegative
variable we've already derived in our handleInput()
method to do something like add the hidden
attribute (ignoring that [hidden] is a lie) to the <slot>
elements conditionally. Then we can show the negative-help-text
slot when !!showNegative
. When !showNegative
, we can show the help-text
slot. And, we're done.
Except, what happens when content is only addressed to the help-text
slot?
Toggling hidden
directly on both <slot>
elements in response to showNegative
would mean that the content addressed to the help-text
slot would be hidden when showNegative
, regardless of whether there was content to display in the negative-help-text
slot at that time. We could only toggle hidden
on the negative-help-text
slot, but that would mean that there are times that our <custom-from-element>
would receive two pieces of help text. Some component authors might want to deliver exactly this functionality to their users, in which case, they'll be ready to go with the above. For those of you that would agree, here's your off ramp.
For those of you who, like me, see more than one type of help text as something to prevent, there are a couple of options available. One would be to leverage the slotchange
event, and the assignedElements()
API on the negative-help-text
slot to decide whether it has content, and when it does use that state in concert with showNegative
to decide when to hide the help-text
slot. One more event listener, one more callback method, one quick question about slotchange
timing and whether you should hold state instead, and you'd be ready to go! But, what if I told you that the browser already had this functionality built directly into it?
Well, it does.
Stacked slots
A <slot>
element doesn't just act as a marker for where content can be projected into your element from the outside. The <slot>
element can also supply default content to be delivered when there is nothing projected onto it, as well. What's more, that content can be additional <slot>
elements. In this way, we can stack our slots and give the earliest ancestor <slot>
in the stack precedence over those that descend from it. For our help text slots, that could look like:
template.innerHTML = /*html*/`
<input aria-describedby="help-text" />
<div id="help-text">
<slot name="negative-help-text">
<slot name="help-text"></slot>
</slot>
</div>
`;
This allows any content addressed to the negative-help-text
slot to "win" and be the content that is shown when it is available. When that is absent, content addressed to the help-text
slot will always be available for users to manage directly from the outside. That means that when HTML like the following is used, only the "This field is required!" content that is addressed to the negative-help-text
slot will be displayed on the rendered page.
<custom-form-element>
<custom-help-text slot="help-text">Please type something here.</custom-help-text>
<custom-help-text slot="negative-help-text">This field is required!</custom-help-text>
</custom-form-element>
This isn't exactly what we were looking for, so we need to add a little something more. We want our parent <slot>
to pass through to our child <slot>
when the form control's value is valid. To do this, instead of relying on hidden
to show or hide the element, we can use a nonsensical name
to ensure content can't be addressed to the slot.
template.innerHTML = /*html*/`
<input aria-describedby="help-text" />
<div id="help-text">
<slot
name="pass-through-help-text-${Math.random()}"
id="negative-help-text"
>
<slot name="help-text"></slot>
</slot>
</div>
`;
This will be our default, allowing the help-text
slot to take precedence. While handling the input
event we can then use the value of showNegative
to toggle the name
attribute to something addressable.
handleInput(event) {
// ...
const target = event.composedPath()[0];
const showNegative = !target.checkValidity();
const slot = this.shadowRoot.querySelector('#negative-help-text');
slot.name = showNegative
? 'negative-help-text
: `pass-through-help-text-${Math.random()}`;
}
This will give our content addressed to negative-help-text
a slot onto which to be projected when our form control becomes showNegative
without preventing content addressed to the help-text
slot from being displayed when negative-help-text
content is absent. It's a bit like API validation in HTML.
With all of this together, we'll be imbuing our <custom-form-element>
with the following super powers:
- accessible help text content
- a
help-text
slot to act as default and receive updates from the outside while displaying in all validity states - a conditional
negative-help-text
slots for overriding that content with content meant only for when the form control shouldshowNegative
It is a neat little custom element who's definition looks about like:
const template = document.createElement("template");
template.innerHTML = /*html*/`
<input
aria-describedby="help-text"
required
/>
<div id="help-text">
<slot
id="contextual-help-text"
name="pass-through-help-text-${Math.random()}"
>
<slot name="help-text"></slot>
</slot>
</div>
`;
class CustomFormElement extends HTMLElement {
get value() {
return this.shadowRoot.querySelector('input').value;
}
set value(value) {
this.shadowRoot.querySelector('input').value = value;
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.appendChild(template.content.cloneNode(true));
this.addEventListener('input', this.handleInput);
}
handleInput(event) {
const contextualHelpTextSlot =
this.shadowRoot.querySelector('#contextual-help-text');
const target = event.composedPath()[0];
const showNegative = !target.checkValidity();
contextualHelpTextSlot.name = showNegative
? 'negative-help-text'
: `pass-through-help-text-${Math.random()}`;
}
}
customElements.define("custom-form-element", CustomFormElement);
Check it out more closely, as well as demos for working with the help-text
and negative-help-text
slots to varying levels of complexity.
What's missing?
Before we wrap up, let's visit a (not so short) list of things that were consciously omitted from the conversation in this article. If you find any that I've unconsciously missed, or if one of these is of particular interest to you, please let me know in the comments below as I'd want to get it added when missing or take your interest as a nice push towards any sequel posts with which I might follow this.
The first to come to mind is giving a definition to our <custom-help-text>
element that is features throughout our demos but until this point has no superpowers of its own.
Revisiting our screenshot of what help text could look like, we've clearly skipped:
- labeling our form element
- supporting API like
required
(see the * mark) on<custom-form-element>
- styling content that we build this way. Yes, I noticed that the visuals in that image are much more appealing than our demos to date
Thinking about the validity and value piping that we added:
- what other APIs (beyond
value
) of our form element should be passed through to our<custom-form-element>
? - what other forms of validity (beyond
required
) should be available and how can we surface them? - how can we make this validity and value a part of a parent
<form>
element's form data? (Yes, the shadow DOM boundary effectively hides the<input />
from participating in our custom element's current form.)
Looking at how we related our help text to our form control, we might also be interested in:
- supporting form controls that are outside of our shadow root
- abstracting this relationship into a reusable form that could empower many custom form control elements, rather than just one
- the benefits of simplifying this code with the help of a declarative templating system
Clearly, there is a lot that we could do to continue to expand on what we've started here together. Some might even involve more learning and praise around <slot>
elements, but most spread across the breadth of APIs and capabilities that are available in the browser today as web components.
But, just because there is a lot to do, doesn't mean we haven't already achieved a lot together.
How far we've come
At press time, our demo features:
- a reusable
<custom-form-element>
element - a single
required
<input />
element on which we track validity - piping for seeing the value of the
<input />
from the outside - the
<input />
is accessibly related to "help text" content that can be supplied from the outside as HTML - a default
<slot>
(help-text
) surfaces a wide array of from the outside customizations around the delivery of the help text - an override
<slot>
(negative-help-text
) that is available when the<custom-from-element>
isshowNegative
To do so we've learned about:
- building a custom element from scratch
- how amazing webcomponents.dev is
- ways to manage property/attribute bloat
- accessibly relating content from the outside of a custom element to content with that element's shadow DOM
- bind events to custom elements
- some basics around boolean attributes
- making state on elements encapsulated within our shadow root public
-
<slot>
elements and their default content - stacking
<slot>
elements
Hopefully, you've found it interesting and educational. Feel free to share anything I could be more clear about in the comments below so that this content can be even more useful for our readers.
After that, you can visit a more advanced version of this technique where it is being leveraged in Spectrum Web Components. There you'll see this functionality delivered as a class factory mixin into custom elements based on the lit library and its LitElement
base class, great fodder for follow up conversations around lending declarative templating and reactivity to our <custom-form-element>
.
Additional editing by @HunterLoftis, who wished after the fact that I had asked him to read this before I asked him to review a PR based on these ideas.
Photo by Aarรณn Gonzรกlez on Unsplash
Top comments (10)
We tend to follow a lock-down philosophy to ergonomically protect consumers from abusing design guidelines. Therefore slots are a โlast resortโ but this can play well if tweaking at least the help message component to feature a label/text property instead of a slot
The line between abuse of and flexibility in design guidelines is quite narrow and an important part of deciding what it is that we ship to our users. Are you finding good success with that? If so, awesome! I'd love to hear what sort of architectural decisions (outside of staying away from
<slot>
s) you've leveraging to make that so.Working from the Spectrum design system, much of my work feels like it should/could be locked down. However, delivering to clients all across and outside of Adobe, I've found that part of continuing to raise adopting of the Spectrum Web Components library is allowing teams to achieve what they see, even if it doesn't yet, or possibly never will, have an official place in the design system. Helping consuming teams to have success as early and often within the design language, while giving a nod to not the design language being a living, breathing things ends up being a good amount of our work ๐
Staying away of slots is one of the methods and this strategy your present do make sense at the end of the day. I'm just asking if here, in
custom-help-text
, isn't it enough to only expose a property rather than a whole slot?I guess it a philosophical question here and how well we trust consumers to have common sense or enough knowledge on how not to break semantics etc'.
In this base version, you might be right.
Even here, there are couple of small scale concepts that this allows for which a property would be less opportune. Those includes application and customization of an icon in the Help Text content, the ability to link to additional information about the requested content or the surfaced error directly in the Help Text, and the ability to surface tooltip like overlays with locally available information about the Help Text also becomes available. Using properties, this could easily become an overly complex system of properties, or require a render prop, which while powerful involves a higher level of investment of the part of your consumers into the technological choices that you've made in building your element.
Beyond that, there's a less immediate, and more nuanced, list of capabilities that working in HTML directly surface that revolve around the immediacy and flexibility of content during the moments in an application lifecycle when JS cannot be guaranteed, but that's a whole other blog post!
In the end, it's very much NOT a question of "right" and "wrong" as much as a question of what "works". Either of these patterns work great at various things, and, when the goals of a project align with them, it's worth investing in them.
I agree, it's not a matter of right / wrong. just a matter of objective, where I see as consolidate a look and feel.
really curious how it feels in a large scale organization - do you find the "liberal" approach used appropriately by the consuming teams? do they follow design guidelines? do their designers follow design system guidelines as expected?
No less tricky, for sure. Namely the products are staffed with super capable people (designers and devs, both) and if they don't find the ability to do something in the Design System/Component Library, they're likely to build it themselves. For all the things that might get applied incorrectly with a liberal API, it's many times more when people are building things from scratch multiple times throughout a company. When they're working from our elements we at least have a finger on the scales, as it were.
With most approaches, I think the possibility of making mistakes runs much higher than we'd like to pretend. One thing we're looking into to reduce this possibility is a dev build of our components, leveraging the exports map in
package.json
, similar to what Lit has been doing in this area. The hope is for it to be a bit like linting on steroids, and when we finally get something together on this, don't doubt that we'll be sharing out our findings.nicee, were refactoring our own lib to a single repo and that's exactly what we're doing as well
Thanks for sharing all this, looking forward to see what you'll come up with...
Nice writeup Westbrook, thanks for publishing.
Can you tell me more about how setting the
name
attr on the parent slot helps you manage the validity state?Did I communicate "helps you manage" somewhere on mistake? If, so I'd love to clarify if you could share more as to where I'm leading readers astray.
The validity state manages whether the parent slots uses a
name
that is non-sensical vsnegative-help-text
so that it only opens thenegative-help-text
slot to receive content when the form control ininvalid
. This empowers the automatic switching between the two types of help text when present.I see so in that way, by swapping between the 'real' slot name and a random one, you're gating access to the shadow DOM for elements slotted into that 'real' slot