Shadow DOM mode as opt-in feature flags
shannonmoeller opened this issue · comments
The Shadow DOM was originally built as an all-or-nothing API to encapsulate a broad range of functionality. Since then, there have been many recommendations around selective means of working around that encapsulation, including mode: "open"
, the ::part
and ::slotted
CSS features, a new open-styleable
mode, and userland requests for such things as Light DOM slots. At the core of each of these requests is the thought, "The Shadow DOM does too much; how can we make it do less?"
What if, instead of debating how to allow people to opt out, we instead look at a way to make Shadow DOM features individually opt-in with feature flags? We could treat mode
more akin to <iframe>
’s sandbox
attribute where multiple flags can be set (though even that API suffers from flags that opt out, instead of opting in).
JavaScript example:
host.attachShadow({
mode: 'encapsulate-ids encapsulate-events encapsulate-styles delegates-focus'
});
Declarative example:
<template
shadowrootmode="encapsulate-ids encapsulate-events encapsulate-styles delegates-focus"
>
I propose we change mode
from being a single-value string to being a whitespace-delimited multi-value string with the following flags to start (the names could change and the list could be longer or shorter):
encapsulate-ids
: Omitting this value is useful for allowing unique items on the page to exist in a declarative Shadow DOM to enable deep linking, id-referencing via aria attributes, id-referencingfor=""
attributes, etc. There is currently no alternative to this feature.encapsulate-events
: Omitting this value is useful for web components that consist of several related but unique form fields and buttons, such as checklists, address fieldsets, credit card fieldsets, etc., as well as component libraries that wrap buttons and inputs with specific styling, but otherwise wish the Shadow DOM children to behave regularly. This would be a simpler alternative to the Form Associated API.encapsulate-styles
: Omitting this value is useful for allowing Light DOM styles to apply to Shadow DOM children. This fulfills the intent of and could be an alternative solution to theopen-styleable
recommendation.delegates-focus
: Allows for setting thedelegatesFocus
option of.attachShadow()
declaratively which I don't believe is currently possible.private-accessor
: Prevents the definition of the.shadowRoot
property on the host element.open
: Would be redeclared as a preset of theencapsulate-ids
,encapsulate-events
, andencapsulate-styles
values.closed
: (default value ifmode
is omitted) Would be redeclared as a preset of theprivate-accessor
,encapsulate-ids
,encapsulate-events
, andencapsulate-styles
values.none
: A value that explicitly disables all other flags. This would solve the intent behind requests for "Light DOM, but with slots." I believe for slots to be effective a host of some kind is still needed. If users wish for slots throughout a page, this would be easy enough to implement by wrapping page contents in<template shadowrootmode="none">
.
I don't believe we need a flag as a means of toggling slots as that appears to be an intrinsic feature of Shadow DOM and it's "toggled" by the existence or non-existence of <slot>
elements within the Shadow DOM markup.
Updating mode
to be a list of feature flags could allow for solving the current needs for granular control of encapsulation while also opening the door for future additional Shadow DOM features without breaking backwards compatibility or declarative usage.
Originally posted by @shannonmoeller in #909 (comment)
[Updated 2024-02-14]: Added the private-accessor
flag to make that opt-in apart from closed
.
@rniwa Here is the new issue as requested. Would you like a new issue per feature flag, or is it enough to discuss the broader idea here before getting that segmented? I realize each of these flags could generate a lot of questions that may need to be discussed in detail in separate threads, but I didn't want to create noise.
Relaxing Shadow DOM encapsulation? I've had strong opinions both ways. But I have a new understanding now as I've eventually found that many of these pain points aren't even a Web Components'/ Shadow DOM's call.
For example, should basic things like style scoping and ID/IDREF scoping really warrant a Shadow DOM? (Look how many years it has taken to realise the answer to the first- in the return of scoped CSS, i.e. @scope
.)
What I see is clear appetite to have most of these features outside of the Shadow DOM, i.e. in the open HTML/DOM land. And my new belief is that the Shadow DOM might be just already great for the right use cases, assuming we find outside answers to the more common non-shadow scenarios, as we're now doing with scope styles.
Put another way, haviing the Shadow DOM as the one means to those ends is what I see as the problem.
Possible related discussions;
While the first is centered on styling, the second is kind of a bigger picture proposal apanning style and script scoping, ID and IDREFS namespacing, etc.
@rniwa Here is the new issue as requested.
Thank you
Would you like a new issue per feature flag, or is it enough to discuss the broader idea here before getting that segmented?
I think we can start with this umbrella issue.
What does not having encapsulate-ids
or encapsulate-events
mean?
@ox-harris I agree with you that having Shadow DOM as the only option for many of the features it provides is an issue. I'm very happy to see the return of @scope
and would love to see more progress in that way (can we get a second act for @apply
?). But I don't think introducing the feature flag idea to Shadow DOM precludes that progress from continuing. My concern is the number of new features that keep getting added to other specs to work around encapsulation rather than just allowing the encapsulation to be opt-in. I see ::part
and ::slotted
as really unfortunate and some of the other proposals of messing with @layer
or introducing new selectors like /shadow/
as complexity on top of complexity rather than eliminating complexity. I want fewer features, not more.
@rniwa Here's how I see those working and being helpful.
Omitting encapsulate-ids
Allowing ids inside of a Shadow DOM to be referenced by things outside of a Shadow DOM enables at least two usecases that I can think of. The first is for single-use page-layout web components that contain landmarks. Consider a blog with posts and comments. It's possible that people may want to share a deep-link to the comments section of a post using https://example.com/blog/post/1234#comments
. You could implement this with a <blog-layout>
component that exposes post
and comment
slots:
<blog-layout>
<span slot="blog-title">Blog post</span>
<article slot="blog-post">Lorem ipsum</article>
<article slot="blog-comment">Dolor sit amet</article>
<article slot="blog-comment">Consectetur adipiscing</article>
</blog-layout>
The <blog-layout>
component could have a Shadow DOM structure that includes landmark ids like so:
<main id="post">
<h1><slot name="blog-title"></slot></h1>
<slot name="blog-post"></slot>
</main>
<aside id="comments">
<h2>Comments</h2>
<slot name="blog-comment"></slot>
</aside>
It is not currently possible to deep link to id="comments"
using the URL hash #comments
. Omitting encapsulate-ids
would allow for this use case. This example could also be implemented with declarative Shadow DOM which is especially helpful for frameworks that provide HTML streaming:
<div>
<template shadowrootmode="none">
<main id="post">
<h1><slot name="blog-title"></slot></h1>
<slot name="blog-post"></slot>
</main>
<aside id="comments">
<h2>Comments</h2>
<slot name="blog-comment"></slot>
</aside>
</template>
<span slot="blog-title">Blog post</span>
<article slot="blog-post">Lorem ipsum</article>
<article slot="blog-comment">Dolor sit amet</article>
<article slot="blog-comment">Consectetur adipiscing</article>
</div>
See: https://stackoverflow.com/questions/43425398/anchor-tag-a-id-jump-with-hash-inside-shadow-dom
The second use case is when It may be helpful for some Shadow DOM roots to be able to expose ids for use by aria
and for
attributes. This is a pretty contrived example, but should serve to illustrate the feature. I'll use declarative Shadow DOM here to make it easier to track what's happening.
<name-label>
<template shadowrootmode="none">
<label id="name-label" for="name-field">Name</label>
</template>
</name-label>
<name-field>
<template shadowrootmode="none">
<input type="text" id="name-field" aria-labelledby="name-label" />
</template>
</name-field>
It is not currently possible to do this. The label and input can have no knowledge of each other. The ElementInternals spec tried to solve this, but is limited to blessing a custom element with additional properties and doesn't get at the real desire. It too is just more layers of complexity.
See: https://nolanlawson.com/2022/11/28/shadow-dom-and-accessibility-the-trouble-with-aria/
Omitting encapsulate-events
Allows events to escape the Shadow DOM without being re-parented which makes it much easier to implement certain things like <custom-button>
. If you let the original click and change events bubble out of the Shadow DOM unmodified you needn't re-emit click events from the internal <button>
of a <custom-button>
nor wire up complicated form-associated callbacks and FormData
handling for things like an <address-fieldset>
component which could expose various sets of fields based on the selected country.
<form action="/buy">
<address-fieldset>
<template shadowrootmode="none">
<input type="text" name="streetAddress" />
<input type="text" name="city" />
<input type="text" name="province" />
<input type="text" name="postalCode" />
<select name="country">...</select>
</template>
</address-fieldset>
<custom-button>
<template shadowrootmode="none">
<button>Click Me</button>
</template>
</custom-button>
</form>
This looks a bit silly using declarative Shadow DOM, but becomes much clearer when you look at an example of what it currently takes to implement a custom button because there is currently no way to disable event encapsulation.
In this CodePen demo, the Light DOM example will submit the form if the button is clicked or the "enter" key is pressed when an input is focused and the form has data, but the Shadow DOM example does not submit and the form has no data: https://codepen.io/shannonmoeller/pen/WNmYZLm?editors=1011
I might be lumping too much together with encapsulate-events
here. There might need to be something else like encapsulate-form-data
for the address fields to work the way I'm proposing. Not sure if it's correct to assume that form data changes are so closely bound to the way events are handled.
[Update 2024-02-12]: Edited the section on encapsulate-events
to clarify the issues with events and form data. Added link to codepen demo of issues.
Few comments:
- what are the benefits of using a whitespace-delimited multi-value string vs individual configurations? I don't see the ergonomics benefits, but I suspect it is not about ergonomics.
- I don't think
encapsulate-ids
is possible, those IDs are defined by doc/fragment. encapsulate-events
is a little bit more subtle, and I'm not sure what's the issue with mode=open and events? if the shadow is open, you can still get to the individual elements by using the event path.- What does
none
means? you said lightdom with slots, but that's not possible as far as I can tell, the slotting mechanism only works if you have composed docs/fragments (essentially a shadow). - I'm also worry about the combinatorial aspect of this proposal, and the overlapping of the options, e.g.: "closed encapsulate-styles", what does that mean? closed has encapsulated styles, how can you make closed to not encapsulate styles?
@gregwhitworth may be interested in this one. I've heard from him that Salesforce has issues with certain encapsulations that cause incompatibilities with legacy scripts. For instance, some jQuery-style widget may get it's container element by doing a query selector for a user provided id. Opting out of querySelector()
encapsulation could help with that.
One thing about this issue is that I don't think the attribute and syntax matter much right now compared to whether it's possible to decouple types of encapsulation in the first place, and what the exact semantics are - ie, if you could opt out of selector encapsulation, would that open the shadow root to queries from scopes above it, or from the document?
Hi, @caridy. Thank you for your questions and the opportunity to be more clear in my proposal. First and foremost, none of what I am proposing is new functionality; it's a means of selectively opting into various subsets of existing functionality that you currently have to use either all together or not at all. I think it will be easiest to answer your questions in reverse order.
5. Because each flag is opt-in, overlap doesn't matter. For example setting mode to "open"
is exactly the same as specifying "encapsulate-ids encapsulate-events encapsulate-styles"
. If you set mode to "open encapsulate-styles"
there is no conflict, you're simply being redundant; it's the same as saying "encapsulate-ids encapsulate-events encapsulate-styles encapsulate-styles"
. To your point closed
is a little harder to describe because I didn't propose a flag to individually opt-in to making the .shadowRoot
property of the host element a private accessor. I'll add a recommendation of a private-accessor
flag to the original post for completeness. At which point setting mode to "closed"
would be exactly the same as setting mode to "private-accessor encapsulate-ids encapsulate-events encapsulate-styles"
.
4. Because each flag is opt-in, specifying "none"
means exactly that: don't encapsulate anything. All ids, events, and styles are shared with the Light DOM. What you still get is a shadow root in which <slot>
elements will still work as expected; so, you essentially get the functionality that's desired when people request "Light DOM with slots." There is still a shadowRoot, so it's not technically Light DOM anymore, but you get the desired conceptual effect.
3. Even with mode="open"
, not all events bubble out of the Shadow DOM to the Light DOM. The ones that do are re-parented to the shadow root's host element. If an open shadowRoot contains a button for example, the click event is re-emitted to the Light DOM as a click event with a target of the host element, rather than having a target of the internal button. Other events, such as change
and input
are blocked entirely and the custom-element author has to re-emit them manually somehow or use the complicated and verbose form-associated APIs. By not specifying encapsulate-events
(leaving this value out of mode
) you are saying you don't want this re-parenting and event blocking to happen. So instead of saying mode="open"
, you would instead say mode="encapsulate-ids encapsulate-styles"
to get everything open
currently gives you, except for messing with events. I made a CodePen demo that you can interact with and watch the console log to get a feel for what's happening currently with mode="open"
. https://codepen.io/shannonmoeller/pen/WNmYZLm?editors=1011
2. The effect of encapsulate-ids
already exists for Shadow DOM. It's not a new feature. What is new is the ability to have this not happen. Instead of mode="open"
you could say mode="encapsulate-events encapsulate-styles"
. Everything else would be the same as open
, except ids would be shared with the Light DOM. This feature only makes sense with declarative Shadow DOM <template shadowrootmode="...">
, or when using custom elements that will be used exactly one time. This is the exact same footgun that already exists when using id
attributes, so I don't see this as posing a new risk. It's simply something you have to know to handle properly as a web developer in general.
1. The benefit of using whitespace-delimited values is that it allows you to set multiple values when using declarative Shadow DOM: <template shadowrootmode="encapsulate-ids encapsulate-events encapsulate-styles encapsulate-styles">
. We could conceivably limit this ability to the shadowrootmode
attribute and require each flag as a boolean option for the .attachShadow
JS API, but making the value of mode
inconsistent based on where you specified it felt wrong and it makes it harder to figure out what options to give to the JS API. Do you omit mode
entirely from the options object? Does that break backwards compatibility since omitting mode
right now defaults to closed
?
@justinfagnani is spot on. honestly, for some of the encapsulation described above (e.g.: styles), I don't think decoupling is possible, at least without a significant amount of work for perhaps some of the encapsulation. For others, (e.g.: ids, and lightdom slots) they just don't make sense, my brain chokes when thinking about that. Let's figure out what's possible, and then work on the ergonomics.
First, thank you @shannonmoeller for filing the issue
+1 to @justinfagnani and @caridy points on the issues discussed to @shannonmoeller proposal
My overall opinion on this has changed over the years and I agree with this statement by @ox-harris
Put another way, haviing the Shadow DOM as the one means to those ends is what I see as the problem.
I would actually recommend the inverse of this and say we should try and figure out how to bring custom elements with <slots>
into the light DOM rather than opening up shadow DOM boundaries beyond what is available via the mode
switch. This also aligns with @ox-harris other point that we're continuing to solve within the Light DOM other problems we originally used shadow DOM for (eg: scoped styles).
I've raised this in the past so I know there is a can of worms for supporting slots
without a shadow tree but I foresee that being a better DX.
In either scenario though, I am curious how a developer would know which capabilities they have when consuming someone else's component without resorting to Javascript which speaks to @caridy point of the potential matrix we have? This is important to consider if I'm using someone else's web component and all I want to do is cross reference an ID or style a child; how do I easily know that's possible? I digress.