Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow slotting indirect children #10273

Open
jakearchibald opened this issue Apr 11, 2024 · 12 comments
Open

Allow slotting indirect children #10273

jakearchibald opened this issue Apr 11, 2024 · 12 comments
Labels
addition/proposal New features or enhancements needs implementer interest Moving the issue forward requires implementers to express interest topic: shadow Relates to shadow trees (as defined in DOM)

Comments

@jakearchibald
Copy link
Collaborator

jakearchibald commented Apr 11, 2024

What problem are you trying to solve?

Imagine a custom element for a custom <select>:

<ui-select>
  <ui-select-group>
    <ui-group-summary>England</ui-group-summary>
    <ui-select-option>London <small>(capital)</small></ui-select-option>
    <ui-select-option>Brighton</ui-select-option></ui-select-group>
  <ui-select-group>
    <ui-group-summary>Scotland</ui-group-summary>
    <ui-select-option>Edinburgh <small>(capital)</small></ui-select-option>
    <ui-select-option>Glasgow</ui-select-option>
  </ui-select-group>
</ui-select>

Sometimes the options will be rendered in their groups in an overlay. Sometimes the selected option will be rendered inline.

Implementation-wise, this would be two slots:

  • One for the overlay content
  • One for the selected item content

When the select is in the closed state, I want to be able to assign the childNodes of the selected <ui-select-option> to the "selected item content" slot.

When the select is in the open state, I want to unassign the nodes in the "selected item content" slot, and assign the <ui-select>'s childNodes to the "overlay content" slot (I may use something like element() to make the selected item appear to be in both places at once).

What solutions exist today?

slot.assign(...nodes)

However, the nodes have to be direct children of the shadow host, so it doesn't work for the use-case above.

How would you solve it?

Allow slottables to be descendants of the shadow root host.

This could be allowed for slot.assign only, or it could be allowed for slottables in general.

I don't know if this would need to be an opt-in behaviour. Right now, surprisingly, calling slot.assign on a node that isn't a direct child of the shadow host, fails silently. Well, technically it succeeds, but the set nodes are ignored in step 5.2 of find slottables.

Anything else?

There are other cases where this would be useful. Eg, cases where the light-dom content of a custom element is a mix of data and content.

For example, the light-dom content could describe a list of books. The shadow DOM may render this as a table, or a list, potentially sorted, depending on the environment and interactions. Again, in this case, the component would want to manually slot the content.

Previous discussions:

@jakearchibald jakearchibald added addition/proposal New features or enhancements needs implementer interest Moving the issue forward requires implementers to express interest topic: shadow Relates to shadow trees (as defined in DOM) labels Apr 11, 2024
@annevk
Copy link
Member

annevk commented Apr 11, 2024

cc @whatwg/components

@mfreed7
Copy link
Collaborator

mfreed7 commented Apr 11, 2024

The questions from @hayatoito in #3534 (comment) would need to be answered. Several look serious, such as event path computation, nested shadow hosts, etc.

@jakearchibald
Copy link
Collaborator Author

jakearchibald commented Apr 12, 2024

For the event path:

host1
├──/shadow-root
│   ├── E
│   │   └── slot1
│   └── F
│       └── slot2
└── A
    └── B
        ├── C (slot=slot1)
        └── D (slot=slot1)

Assuming C is clicked, It feels like there are two choices for the event path:

The path goes into the shadow DOM after the slotted element, then returns to the light DOM:

  1. C
  2. E (in shadow)
  3. B (in light)
  4. A
  5. host1

The path goes up to a host's child before going into the shadow DOM:

  1. C
  2. B
  3. A
  4. E (in shadow)
  5. host1 (in light)
  • If you resolve this issue naively, A 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".

I guess my second option is the 'naive' one. I get the point above, but I'm not sure why this is bad in practice. Is the first option better? I suppose it has the same 'problem' but it's B that has multiple parents.

@jakearchibald
Copy link
Collaborator Author

jakearchibald commented Apr 12, 2024

Think how your proposal work with: Two nodes are assigned to the same slot (or different slots) in the same shadow tree, however, one node is an ancestor of the other.

I think this is fine with slot.assign.

  1. slot.assign(child) - this node is now rendering in the slot
  2. slot.assign(ancestor) - child is unassigned, ancestor is assigned instead. ancestor is now rendering in the slot, as is child as part of that.

You get a different result if you change the order.

I guess this is why it doesn't work in a declarative system, since there isn't a defined order, but with slot.assign there's explicit ordering.

For completeness:

  1. slot1.assign(child) -child is now rendering in slot1
  2. slot2.assign(ancestor) - ancestor is rendering in slot2, but not child, as that's still assigned to slot1.

@jakearchibald
Copy link
Collaborator Author

jakearchibald commented Apr 12, 2024

3. Think your proposal's impact on a nested web components case

host1
├──/shadow-root
│   └── slot1
└── A
    └── B
        └── C
            └── C
                └── D
                    └── E
                        └── F (-> slot1)

The flat tree would be:

host1
└── slot1
    └── F

Think what happens: attach shadow to B (and append slot2 to B' shadow root)

host1
├──/shadow-root
│   └── slot1
└── A
    └── B
        ├──/shadow-root
        │   └── slot2
        └── C
            └── C
                └── D
                    └── E
                        └── F (-> slot1)
  • Think about the relationship between slot1, slot2 and F.
  • In addition to that, think what happens if D is assigned to slot2 later
host1
├──/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.

slot1 would have its slottable set to F, slot2 would have its slottable set to D. F would render in slot1, and D and E would render in slot2. F (and any children) wouldn't render in slot2, because it's assigned to another slot.

Does this hit the performance issue?

@jakearchibald
Copy link
Collaborator Author

@rniwa you've looked at this in the past. How do you feel about the 'answers' to the questions above? Any no-gos?

@jakearchibald
Copy link
Collaborator Author

@mfreed7 are the above comments reasonable answers to the questions?

@mfreed7
Copy link
Collaborator

mfreed7 commented Apr 25, 2024

@mfreed7 are the above comments reasonable answers to the questions?

I'm not sure. All of them are tractable and implementable (I think) but all are "weird" in some way.

On the event path question, both paths are weird in some way. I think I prefer the first one (path goes into the shadow root first, then magically comes back out at the parent of the slotted element) but I'm not sure. Both are pretty hard to follow and understand, for developers.

For the assignment of relatives:

  • slot.assign(child) - this node is now rendering in the slot
  • slot.assign(ancestor) - child is unassigned, ancestor is assigned instead. ancestor is now rendering in the slot, as is child as part of that.

Sure, that's great, but that's the easy case since each call to assign() clears out the manually assigned nodes. What happens if you do slot.assign([child, ancestor]) or slot.assign([ancestor, child])? And here:

slot1.assign(child) -child is now rendering in slot1
slot2.assign(ancestor) - ancestor is rendering in slot2, but not child, as that's still assigned to slot1.

So you're proposing to allow a child node to be slotted independently of its parent node? That feels like an implementation nightmare, since for every tree walk you'll need to check whether the walked node (at arbitrary depth) is slotted into some slot somewhere. It's unclear whether that condition is that the node is slotted, or just shows up in the manually assigned nodes list.

For the tree examples, this whole scenario scares me:

slot1 would have its slottable set to F, slot2 would have its slottable set to D. F would render in slot1, and D and E would render in slot2. F (and any children) wouldn't render in slot2, because it's assigned to another slot.

There are so many things going on there. But the weirdest of them is still that D & E are in one slot, but E's child F is in another slot.


I think many of these problems might resolve themselves when a prototype implementation is built. It's a bit hard to think about all of the corner cases without actually doing an implementation. I'm supportive of trying to solve the use case, since I think it'd definitely be helpful to be able to slot non-direct children of the host. I'm just worried about all of the grandfather paradoxes.

@jakearchibald
Copy link
Collaborator Author

jakearchibald commented Apr 26, 2024

@mfreed7 thanks for taking the time to think this through.

What happens if you do slot.assign([child, ancestor]) or slot.assign([ancestor, child])?

So:

  • host
    • shadow root
      • SA
        • slot
    • A
      • B
        • C
          • D

Calling slot.assign(B, D) would render as:

  • host
    • shadow root
      • slot
        • SA
          • B
            • C
          • D

slot.assign(D, B) would be similar, except the order of B & D would be reversed.

This raises questions about the event path if D is clicked. Does it go D, SA, slot, C, B, SA, slot, host? It seems weird elements would be in the path twice (could there even be cases where a loop is created?). You could skip items already in the path, so it'd be D, SA, slot, C, B, host. It's definitely new and weird, but it's what you've asked for as a developer.

So you're proposing to allow a child node to be slotted independently of its parent node? That feels like an implementation nightmare, since for every tree walk you'll need to check whether the walked node (at arbitrary depth) is slotted into some slot somewhere

Is it really that crazy?

<div id="a">
  <div id="b">
    <div id="c">
      <div id="d"></div>
    </div>
  </div>
</div>
<style>
  #b { --slot: foo; }
  #d { --slot: bar; }
</style>

Given the above, I can easily query the 'slot' value for any element in that tree using getComputedStyle. We've been handling that kind of cascading for decades (with things like CSS color).

But the weirdest of them is still that D & E are in one slot, but E's child F is in another slot.

Maybe we could scope this down. Eg "If an ancestor and child are slotted, the child slotting is ignored (parentmost wins)"

The use-cases I can think of can still be solved with those restrictions. And, those restrictions could be removed in future, if use-cases appear.

@mfreed7
Copy link
Collaborator

mfreed7 commented May 7, 2024

@mfreed7 thanks for taking the time to think this through.

No problem. Again, I can see the desired use case, I'm just concerned about the consequences.

This raises questions about the event path if D is clicked. Does it go D, SA, slot, C, B, SA, slot, host? It seems weird elements would be in the path twice (could there even be cases where a loop is created?). You could skip items already in the path, so it'd be D, SA, slot, C, B, host. It's definitely new and weird, but it's what you've asked for as a developer.

Yes, these are exactly my questions. It feels very odd to have the same element occur an arbitrary number of times in the event path. (I don't think it's possible to get a loop, but perhaps I haven't thought creatively enough.) I wonder if it would be easier to just bar this type of assignment? I.e. if you do slot.assign(B, D) then D is ignored since it's in the subtree of B, and B comes first in the list? And slot.assign(D, B) would ignore B because it's an ancestor of D and it comes first. I'm sure there is some use case of mangling the tree like this, but are there clean ways around them? Or is this a key use case? (We haven't talked a lot about use cases in this issue - perhaps we should?)

Given the above, I can easily query the 'slot' value for any element in that tree using getComputedStyle. We've been handling that kind of cascading for decades (with things like CSS color).

Yes, that's true, but CSS selector matchers are built around the cascade, while DOM code is not. This issue represents the changing of an invariant that is baked in to many parts of current DOM code to deal with shadow roots.

Maybe we could scope this down. Eg "If an ancestor and child are slotted, the child slotting is ignored (parentmost wins)"

The use-cases I can think of can still be solved with those restrictions. And, those restrictions could be removed in future, if use-cases appear.

Ahh maybe this answers my question above. I do think it would dramatically simplify things. I like your way out - parentmost wins. That feels clean from a developer's point of view, but the implementation still has questions. Like "when" does the "winning" happen. When you call assign()? Or (more likely) when slot assignment takes place? If it's the former, you get weirdness because the tree can be mutated after the call to assign. If it's the latter, then you need to do a decent amount of tree walk work every time you do slot assignment, to determine which nodes are ancestors of which other nodes.

@jakearchibald
Copy link
Collaborator Author

Yes, that's true, but CSS selector matchers are built around the cascade, while DOM code is not. This issue represents the changing of an invariant that is baked in to many parts of current DOM code to deal with shadow roots.

Fair. I don't have a feeling for how much work this would be. Does #5033 (comment) represent a similar problem, where the heading level is incremented by multiple ancestor elements?

"when" does the "winning" happen. When you call assign()? Or (more likely) when slot assignment takes place?

Yeah, I agree that the latter feels better, DX wise.

@mfreed7
Copy link
Collaborator

mfreed7 commented May 10, 2024

Yes, that's true, but CSS selector matchers are built around the cascade, while DOM code is not. This issue represents the changing of an invariant that is baked in to many parts of current DOM code to deal with shadow roots.

Fair. I don't have a feeling for how much work this would be. Does #5033 (comment) represent a similar problem, where the heading level is incremented by multiple ancestor elements?

I haven't thought a ton about it, but my knee jerk reaction is that the heading level incrementer is quite a bit more localized and simple. It'll just require an int to be stored that can be incremented as headings are walked. The change here involves any code that does walks over the flat tree, which happen in multiple places, explicitly and implicitly.

"when" does the "winning" happen. When you call assign()? Or (more likely) when slot assignment takes place?

Yeah, I agree that the latter feels better, DX wise.

Kind of has to be, at least to stay true to how assign() works today.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
addition/proposal New features or enhancements needs implementer interest Moving the issue forward requires implementers to express interest topic: shadow Relates to shadow trees (as defined in DOM)
Development

No branches or pull requests

3 participants