Writing down the stuff we talked about with @rniwa, @hayatoito, et al yesterday:
We should add a slotElement.reassign(...nodes) which reassigns the nodes.
How does this interact with the existing declarative distribution API? Maybe it just throws if the name="" attribute is specified at all; that seems nice and simple.
This API is not "perfect" because authors don't have enough hooks to call it as often as you might want. E.g. if you are trying to emulate details/summary, you will use a MutationObserver to watch for child node changes and do slotEl.reassign(theDetailsIFound). This happens at microtask timing, which is later than is done for the browser's native details/summary (people tell me that happens at style recalc timing).
But, it's pretty darn good!! I'm really excited about not putting slot="" attributes everywhere.
Any further thoughts?
Thanks for filing a new issue. Yup. I think that is feasible. Let me explore API design and its semantics deeper.
I am also wondering whether there is a use case which imperative API can't address, or not. Please let us know if there is. I hope this kind of imperative API can address the rest of the world.
Maybe it just throws if the name="" attribute is specified at all; that seems nice and simple.
That wouldn't address the elements potentially getting slotted elsewhere.
This will also require some careful study of the mutation algorithms (a fair number of which simply reset the assigned nodes).
Yup, my concern is that we might have to update a lot of places in DOM Standard, depending on when we should reset imperatively specified "assigned nodes". I am sure we have to reset it somewhere.
It requires careful study.
Thanks. I am afraid I am not enough of a shadow DOM/DOM spec expert to do that careful study. But, I will try to coordinate folks into answering the question of
I am also wondering whether there is a use case which imperative API can't address, or not. Please let us know if there is. I hope this kind of imperative API can address the rest of the world.
Hopefully if we can answer "yes", then this would be high-priority enough for you experts to help us write the spec?
One thing we should decide is how declarative APIs and imperative APIs interact each other. Mixing them is troublesome and would be the cause of a confusion.
One idea to make the situation much simpler is to get "opt-in" from web developers to allow them to use imperative APIs. The scope of 'opt-in' should be a shadow tree.
e.g.
attachShadow({ mode: 'open', slotting: 'manual' /* tentative parameter name */ });
In other words, declarative APIs and imperative APIs should be mutually exclusive in one shadow tree.
We don't mix them in the same shadow tree. That would make the situation much simpler, I think.
I don't think that necessarily helps, since if you have another shadow root that's not manual you'll still run into many of the same questions. Either way we'll have to deal with what happens when a node is already assigned.
cc @whatwg/components
I'll post a straw-man proposal, based on my idea.
I've posted my straw-man proposal here.
I hope this can capture most use cases, with minimum changes to DOM Standard, HTML Standard, and browser's engines.
So what happens if you have two (parallel) host elements and I manually assign children (its slotables) from the first host element to the shadow tree slots of the second? How does that not run into the issues I alluded to earlier?
They are never used in other shadow trees. Let me show an example.
βββ/shadowroot1 (slotting=manual)
β βββ slot1
βββ A
host2
βββ/shadowroot2 (slotting=manual)
β βββ slot2
βββ B
slot2.assign([A]);
assert(slot2.assignedNodes() == []);
slot1.assign([A]);
assert(slot1.assignedNodes() == [A]);
shadowroot2.append(slot1);
assert(slot1.assignedNodes() == []);
shadowroot1.append(slot1);
assert(slot1.assignedNodes() == [A]);
But wasn't the point of the imperative API that you were not restricted on where the elements came from? At least, it sounds like you want them to be restricted to children of the host element? How does this follow from your document?
No. The bottom line is that assigned nodes should be the host's children. That shouldn't change. We don't relax this restriction.
And _manually-assigned-nodes_ too? And everyone agreed on that? Still unclear why the proposal does not state that though or why assign() succeeds despite failing.
I didn't introduce any restriction to manually-assigned-nodes. Any programmer's mistake can be okay there by design. Invalid nodes in manually-assigned-nodes are never selected as assigned nodes. assigned nodes are only observable.
I think this design choice would make the standard and the implementation much simpler.
Anyway, let me state that clearly.
Any alternative ideas are welcome, of course. I would like to hear feedback.
I've added some clarification and an example. Thanks!
That won't quite work if we allowed slotElement.reassign to select a non-direct-child descendent of a host element in nested shadow trees, or if we allowed some arbitrary node elsewhere in the trees and had two shadow trees.
Consider when the case when (x) is manually assigned of (d). In that case, (d) would belong to both (x) and (y), which shouldn't be allowed.
```
a (outer host) ---- SR (manual)
I think a lot simpler model is to keep track of the slot to which a given node is manually assigned, and forbid declarative slotting from picking that node.
Namely, each node will have an internal slot manually slotted flag, which is initially set to false. Each time slotElement.reassign is invoked (and when the slot is removed from a shadow tree, etc...), we update this flag's value. The existing slotting algorithm would simply ignore any node with this flag set to true.
I'll add that we probably don't want to have a mode being set at a shadow root level. details element, for example, wants to use the default slot to get everything but the first summary.
Consider when the case when (x) is manually assigned of (d). In that case, (d) would belong to both (x) and (y), which shouldn't be allowed.
I think there is misunderstanding. Even if slotX.assign(d) is called, slotX.assignedNodes() doesn't contain d, according to my proposal. d is never used as assigned nodes of slotX because d is not the host a's child.
Even if the same node is added to manually-assigned-node in more than one different slots, the node should appear at most one slot's assigned nodes in my proposal.
See Example 3, where A is manually assigned to slot1 and slot2, however, A does not show in slot2's assigned nodes.
@hayatoito, the proposal is looking good from the perspective of doing the manual allocation, but it is not providing enough information, or end-to-end examples.
My main concern is that with manual, no auto allocation/slotting will be done, which probably mean that you cannot use slotchange event to observe changes in your slots. The question is, how will the developer know when to do the manual allocation? And the answer cannot be: "just use mutation observer".
I think from our side, we will like to have a reliable signal for changes on the light tree so the allocation can happen accordingly. The most dummy example could be:
<fancy-menu>
<menu-item>one</menu-item>
<menu-item>two</menu-item>
</fancy-menu>
When adding a new menu item (e.g.: myFancyMenuElement.appendChild(menuItemThreeElement)), we should be able to detect that operation, and take care of the manual slotting on the spot.
/cc @diervo
Please explain why the answer cannot be mutation observers? They seem perfect for this case.
I can envision scenarios in which a developer would like to mix slotting by name and/or default slotting with manual slotting.
Suppose an app has a menu that shows menu items, some of which are conditionally available. Perhaps it supports markup like:
<menu-element>
<div slot="caption">Menu</div>
<menu-item show-when="signedout">Create account</menu-item>
<menu-item show-when="signedin">Account settings</menu-item>
<menu-item>Help</menu-item>
</menu-element>
And suppose menu-element has a template like:
<template>
<slot name="caption"></slot>
<slot name="availableItems"></slot>
</template>
This menu element wants to leverage preexisting support for slotting by name βΒ in this situation, slotting a caption into the caption slot is easily managed by adding name="caption". At the same time, the menu wants to manually assign the appropriate menu items to the availableItems slot based on a condition evaluated at runtime.
Could the proposal be extended to allow, when slotting is manual, nodes to be assigned to named slots as usual? The idea is that named slotting happens first, then the dev can do what they want.
Along the same lines, I could imagine wanting to have a default slot still serve as the default destination even when manual slotting is being used for specific slots. It would be useful for a component to manually pluck the nodes it wants to assign to specific slots, then let everything else fall into the default (unnamed) slot.
In short, rather than having manual mode entirely disable existing behavior, the new imperative API could extend it. As I read the proposal, the new proposed "Find a slot" step could drop the "If shadow's slotting is manual" condition:
[Note: the existing text of step 6 above fails to mention the default slot, but probably should.]
If such an accommodation could be found, it might allow the introduction of an imperative slotting API without needing to introduce a slotting mode at all.
(Side note: If it'd be helpful to fork this topic into a separate issue, I can do that. Perhaps someone who can create labels on this repo could set up a label for imperative distribution?)
Thanks for the feedback. I don't have enough bandwidth to reply all today, so let me reply in the next week.
I have a plan to add "Alternatives Considered" to the proposal.
@caridy
Our assumption is that users can use MutationObservers.
[Update: I understood that the following explanation is not directly related to @caridy's concern, but let me keep the following explanation, as a side note, because the concept of manually-assigned-nodes is likely to cause a confusion to end-users.]
We still auto-calculate assigned nodes for each slot in a shadow tree, using the information of manually-assigned-nodes users gave for each slot. manually-assigned-nodes is just a hint for an engine. The engine can't trust manually-assigned-nodes as is because manually-assigned-nodes may include an invalid node which can't be used as a member of assigned nodes.
If assigned nodes are changed as a result in some slots, slotchange is fired at the end of microtask timing for the slots even when they are in manual.
I think there is misunderstanding. Even if
slotX.assign(d)is called,slotX.assignedNodes()doesn't containd, according to my proposal.dis never used as assigned nodes ofslotXbecausedis not the hosta's child.
Okay, then your proposal doesn't satisfy a major use case of the imperative API to select a non-child node of a shadow host. I don't think that's okay. An imperative API should allow slotting of an arbitrarily deep descendent node.
It would help to have some concrete examples of elements that want to slot a deep descendant. I'm unsure if select/optgroup/option would count here (i.e., would select slot both options and optgroups, or would it slot its children, and then optgroup slots its children).
Nevertheless, I agree it seems like something we should be aiming for, especially if we want to fulfill
I hope this kind of imperative API can address the rest of the world.
See https://github.com/w3c/webcomponents/issues/574 for a concrete example.
Just to be clear, we'd be opposed to any imperative API proposal which doesn't support this use case since we see this as one of the primary motivations for having imperative API at all. In fact, the only reason we receded our proposal to support this in declarative syntax was one of Google representatives made an argument that we can support this in imperative API.
Regarding https://github.com/w3c/webcomponents/issues/574 (slotting indirect children),
I remember https://github.com/w3c/webcomponents/issues/574, where I pointed out why "slotting indirect children" is a non-starter from the theoretical and practical perspective, however, I didn't get any response on that. It looks that only I have explored this problem space so far.
To avoid answering the same question repeatedly, it would be better to share my insights clearly here. Please read these and think carefully by yourself before filing a request for "slotting indirect children".
First of all, supporting "slotting indirect children" is NOT a nice-to-have feature. It is a sort of an attempt to introduce "division-by-zero" to the beauty of the Shadow DOM world. That is infeasible.
In other words, "Only host's children can be distributed" is MANDATORY. That has been the fundamental requirement to make Shadow DOM work, from the very early days of Shadow DOM.
For those who will explore this problem from now, let me share several things which you will encounter, hoping this would be helpful to understand what problems you are trying to solve:
Think the following simple case.
βββ/shadow-root
β βββ E
β β βββ slot1
β βββ F
β βββ slot2
βββ A
βββ B
βββ C (slot=slot1)
βββ E (slot=slot1)
And think how you can update get the parent algorithm so that weird things shouldn't happen, such as:
C, receives an event, however, its ancestor nodes, A or B, doesn't receive the eventA would have more than one parent nodes, regarding an event path. A's parents would be slot 1 and slot2. That would depend on the context; where an event happens. "Tree" behind the event path is no longer "Tree".βββ/shadow-root
β βββ slot1
βββ A
βββ B
βββ C
βββ C
βββ D
βββ E
βββ F (-> slot1)
The flat tree would be:
βββ slot1
βββ F
Think what happens: attach shadow to B (and append slot2 to B' shadow root)
βββ/shadow-root
β βββ slot1
βββ A
βββ B
βββ/shadow-root
β βββ slot2
βββ C
βββ C
βββ D
βββ E
βββ F (-> slot1)
βββ/shadow-root
β βββ slot1
βββ A
βββ B
βββ/shadow-root
β βββ slot2
βββ C
βββ C
βββ D (-> slot2)
βββ E
βββ F (-> slot1)
Think about more complex scenerio and how your concrete proposal can have a reasonable answer for that, from the performance's perspective. e.g.. to avoid O(n) traversing.
The use case in https://github.com/w3c/webcomponents/issues/574 didn't make sense to me. I pointed out, "Remove unnecessary <my-tab> from the markup" there. <my-tab>'s role there is just a comment node in the markup, effectively.
Only remaining appealing point for that seems to me:
For example, if you want to remove, move or add a tab, then you could do this with one command.
However, in general, DOM doesn't allow inserting such a "no-op" comment container node in the markup.
If you need such a "no-op" comment container node in your markup, you should file an issue for DOM, instead of here. It is unclear to me why only Shadow DOM should support such a weird requirement.
And, I've never heard such a weird requirement from actual users of Web Components, such as Polymer team.
I have showed a couple of examples here, however, I am pretty sure these are not only things which would be broken. So please try to have a concrete proposal, instead of just replying to each example. Please don't let me guess how your proposal would be. That would be unproductive for us.
I am happy to review a proposal if someone has explored this problem space deeply and still can have a concrete proposal which can support "slotting indirect children".
The assertion that not being able to assign a non-direct child to a slot is a requirement for shadow DOM is utterly false.
In fact, I've explored this problem space and prototyped such a model. I can post a detailed proposal later (not possible in the next few weeks or months due to other commiments) but I figured you can sort it out yourself; I gusss not.
My earlier comment withstands. Any proposal for an imperative slotting API that doesn't support assigning a non-direct child is a show stopper for Apple. It was a show stopper four years ago, and it is a show stopper today.
I'm quite surprised that we're having this conversation again because I felt like we made our position very clear then. It's one issue we can't compromise.
Since @rniwa is not able to help us for the next few months, I am wondering if it's worth moving forward with a child-only version, that can then in the future be extended to support more descendants when @rniwa has time to help us figure out the model?
I understand it's a must for @rniwa to solve arbitrary descendants. But children are a subset of descendants, so if we can come up with a subset proposal that can be extended in the future, we may be able to make progress instead of stalling for months.
That seems somewhat unrealistic given that @hayatoito is unclear on how a descendants-solution would work.
Right, but apparently @rniwa is clear apparently, so once he is able to find the time, he can extend the child-only solution to work with descendants.
What makes you think they're compatible though?
Well, a child is a special case of a descendant.
Could a concept more similar to "portals" (which various frameworks today have, f.e. React portals and Vue portals) possibly be better, as alternative to the proposed deep slotting?
It would be great for whatever solution to be super obvious (explicit) about when your element is rendered to another place besides the immediate parent, and not unexpected.
For this to be true, it may be the mechanism has to be only imperative and reference based. It would be great if only a Custom Element could explicitly do teleporting to some portal by reference, perhaps using a private API (see https://github.com/w3c/webcomponents/issues/758 about giving APIs to CE authors).
Basic idea:
<body>
<div>
<portal></portal>
</div>
<div>
<my-el></my-el>
</div>
</body>
// Inside MyEl class
const portal = document.querySelector('portal')
private(this).teleport(portal) // my-el renders relative to where portal is located
(Private class fields are coming soon.)
This idea is not tied to ShadowDOM. But for it to work in ShadowDOM, a component author would place it in a root then expose the portal reference to the outside.
A (grand)child element of the component could traverse up the tree to find the component from which to get the reference from (f.e. in connectedCallback).
Outside code _cannot_ get a reference to the my-el and make it teleport, unless the element makes the teleport mechanism public. This allows for creating the guarantee that the element will explicitly know where it teleports to without surprises. However the CE author can decide to forgo this guarantee by exposing the teleport mechanism to the outside.
The connection is made purely imperatively (the .teleport feature), no declarative option.
@joelrich This would work for your case in https://github.com/w3c/webcomponents/issues/574 I think.
(I know this is not a proposal, I'm just throwing in an idea to get cogs turning)
Summary of the discussion at TPAC F2F. @rniwa @smaug---- pls correct anything. @hayatoito if you see things that are impossible or very difficult, please let me know.
someSlot.setAllAssigned(elementList) which allows the author to set which elements are assigned to the slot and their order. It is absolute, not incremental, any elements not in elementList are not assigned to the slot any moreare slot change events still needed? Reassignment is done by the element itself, so it can notify itself. If a slotted element is removed from the light tree, that can be seen by a mutation observer. @rniwa gave an example involving nested slotting but slot-change events are not composed, so I don't understand the example. @rniwa please clarify. Keeping the event may just be a good idea anyway.
Remember that composedness is only considered about whether the event goes out of the current shadow tree to outer trees, not about whether it ever cross shadow trees. Uncomposed events will indeed enter inner shadow trees, and that's where slotchange will be useful as inner shadow tree's slot elements can observe changes to slots assigned to them. To see why, consider the following case:
A --- SR(A)
+ B + C --- SR(C)
+ S1 + S2
Here, A, B, C, are elements and SR(A) is the shadow root of A, SR(C) is the shadow root of C, and S1 and S2 are slot elements. S1 is assigned to S2. If B is imperatively assigned to S1, it's important for S2 to get notified of this assignment. Becauseslotchange dispatched on S1 will have the event path of S1, S2, SR(C), C, SR(A), dispatching slotchange event on S2 would achieve that goal.
@fergald The example I provided in the meeting was the one I posted above, in which a single menu element had a slot that wanted normal, declarative distribution and another slot that it wanted to manage imperatively:
And suppose
menu-elementhas a template like:<template> <slot name="caption"></slot> <slot name="availableItems"></slot> </template>This menu element wants to leverage preexisting support for slotting by name βΒ in this situation, slotting a caption into the
captionslot is easily managed by addingname="caption". At the same time, the menu wants to manually assign the appropriate menu items to theavailableItemsslot based on a condition evaluated at runtime.
However, I no longer think this is an issue. Someone nice person at the meeting (can't remember who) pointed out in a breakout discussion that, in situations like this, the menu element could factor itself into an outer element whose slots were all declaratively assigned, and one of those slots was composed inside an inner element that handled the imperative distribution:
<template>
<slot name="caption"></slot>
<inner-element-with-imperative-distribution>
<slot name="availableItems"></slot>
</inner-element-with-imperative-distribution>
</template>
I think some arrangement like that would be sufficiently flexible for us that, for now at least, I don't see the need to have both declarative and imperative slots in the same shadow tree.
@JanMiksovsky I feel like we shouldn't be forcing people to split code that logically belongs together (although there are probably plenty of cases when this split _does_ make sense).
I think something like this also solves the problem
function emulateDeclarativeSlotting(slot, name) {
slot.addEventListener('slotchange', function(e) {
let newNodes = host.querySelectorAll(`[slot=${slotName}]`);
// Avoid infinite cascade of events
if (!sameNodes(newNodes, slot.assignedNodes()) {
slot.setAllAssigned(newNodes);
}
}
but you would want to use it sparingly as the performance could be bad.
// Avoid infinite cascade of events
if (!sameNodes(newNodes, slot.assignedNodes()) {
Hmm. Can we specify that slotchange is only raised if the set of nodes actually changed? It seems like that would prevent common mistakes, and let someone emulate declarative slotting more easily:
function emulateDeclarativeSlotting(slot, name) {
slot.addEventListener('slotchange', function(e) {
let newNodes = host.querySelectorAll(`[slot=${slotName}]`);
slot.setAllAssigned(newNodes);
}
}
Yeah, slotchange should only fire if the list of nodes change.
Makes sense. To be clear. We should fire the event if the contents or order changes.
As I started implementing this API, the following scenario came up, and I want to make sure we are all in agreement.
Moving a slot within its shadow root, with imperative slot assignment, clears out its assigned nodes.
Ex:
host1
βββ/sr (manual-slot-assignment)
β βββ slot1
β βββ slot2
βββ A
βββ B
slot1.assign([A]);
slot2.assign([B]);
sr.appendChild(slot1);
assert(slot1.assignedNodes() == []); // assigned nodes are cleared.
assert(slot2.assignedNodes() == [B]);
slot1.assign([A]);
sr.insertBefore(slot1, slot2);
assert(slot1.assignedNodes() == []); // assigned nodes are cleared.
assert(slot2.assignedNodes() == [B]);
slot2.remove();
assert(slot2.assignedNodes() == []); // assigned nodes are cleared.
sr.append(slot2);
assert(slot2.assignedNodes() == []);
This behavior looks to be the correct, because both appendChild(node) and insertBefore(node, refNode), runs the adopting steps for the node whereby it is removed from its original parent, https://dom.spec.whatwg.org/#concept-node-adopt. During removal, it triggers the assign slottables algorithm and clears out all the slot's assigned nodes since this slot is no longer a descendant of SR.
This result, however, differs from that of a shadow root with declarative slot assignment. When moving slots around within its shadow root via appendChild() or insertBefore(), assign slotables algorithm is rerun after inserting the node, and the previously assigned nodes gets reassigned again.
I want to double-check that we are in agreement with this behavior and also make sure web developers are aware of this difference between imperative and declarative slot assignment.
@domenic, @annevk, @rniwa, @JanMiksovsky
Thanks.
Iβve been playing with this in Chrome Canary with the flag to see if it meets my needs. I think it does, so wanted to share that feedback somewhere (apologies if this isnβt the best place).
I was initially worried that the microtaskiness of MutationObserver might be a problem because I have some API that exposes values which are derived from the current assignment state β and it would be weird if some sync code appends a child + checks one of these methods since it would get the stale state.
However MutationObserver.prototype.takeRecords actually seemed to address this exact problem very well. At the point where observation is attempted, I can ensure the assignments are βflushedβ before calculating the result.
In my case, wiring this up required a lot of code! But after messing around further, I was able to abstract it away. βDeclarative imperative slottingβ (...) is made possible; e.g. static slots = { menuitems: [ 'xx-menuitem', 'xx-menusep' ] };, where "menuitems" is a slot name and a LitElement-like class manages assignment of elements of those types to the slot with that name.
I suspect "I want elements of these types to be slotted in this slot" is going to be the most common usage by far, so if there is a path to make that simpler to express in the HTML API itself, itβd be awesome, but I appreciate the additional power afforded by the current API design; itβs nice to know I could make things weirder if needed :)
@bathos,
Thanks for trying out this API. Your feedback is greatly appreciated. And I'm happy that it meets your needs.
You mentioned that initially it required a lot of code to wire it up. What was the complexity and how did you simplify it? Do you have some code to share?
I thought as an alternative, instead of providing a sync state check for your component, you could dispatch an async event when the component state changes. Could that made it easier?
Thanks.
You mentioned that initially it required a lot of code to wire it up. What was the complexity and how did you simplify it? Do you have some code to share?
I canβt share the code, but I can describe what Iβd ended up doing in more detail. Thereβs an βElementDefinitionβ class in play β itβs a kinda meta builder thing, not itself a base class extending HTMLElement. I added support for a slots option to its API. If present, this was expected to be an object of the form { slotname: [ tagname, ... ] }. The generated elementβs behavior was augmented in this case to init a mutation observer on its own childlist and its callback performed the imperative slotting behavior according to the βrulesβ defined by the slots option. The individual elementβs own implementation code was already given access to an internal interface (i.e., one created/managed by ElementDefinition), so I extended this with an ensureSlotAssignments method. That method is what would takeRecords; the point being to provide a way for the element to always report truthfully if βaskedβ something which actually depended on the current state of what has been slotted where.
I thought as an alternative, instead of providing a sync state check for your component, you could dispatch an async event when the component state changes. Could that made it easier?
Well, the point there was that this is not what we wanted, but yes, if we got rid of the accessors that cared about what had been slotted and made them (and everything everywhere else that depended on them in some way) into async methods instead, we would not need to use takeRecords. We would also end up with an unidiomatic API that doesnβt resemble that of any regular element β thankfully things like node.parentNode or selectElement.options donβt evaluate to promises :). But since takeRecords does work, and since the related complexity of MutationObserver can be tucked away in helper code, it seems to have worked out for us in any case.
Another scenario I just recalled where the ability to ensure all assignments were βflushedβ synchronously came up in (UI) event handling. In this case, the specific set of applicable keyboard and pointer events depended on whether an element was a leaf-node menuitem or a menuitem which hosted a child menu β which here meant that a <glyf-menu> had been slotted in a <glyf-menuitem>. This handling canβt avoid deciding whether to preventDefault synchronously. I realize it would be pretty difficult to actually notice the state disconnect in most scenarios, but itβd be apparent in imperative code trying to leverage ordinary element APIs. For example, menuitem.append(menu); menuitem.click() should perform the behavior which is applicable when the menuitem has a valid submenu (opens it); that code shouldnβt require a setTimeout or something after appending, IMO.
tl;dr I guess is that using MutationObserver for this by default moves the act of slotting into a queued microtask, which is an awkward difference from what happens for declarative slotting, but because of takeRecords, it is not an insurmountable difference. I care more about the platform providing the capability than about it having an ideal API, since the latter can be corrected on our end but the former cannot be, so given the capability exists, I consider this workable personally.
Most helpful comment
Makes sense. To be clear. We should fire the event if the contents or order changes.