sveltejs / svelte

Cybernetically enhanced web apps
https://svelte.dev
MIT License
78.3k stars 4.1k forks source link

Component should be able to iterate over child components in `<slot />`. #5381

Closed ekawatani closed 3 years ago

ekawatani commented 4 years ago

Similar issues have been raised and they have been closed, but I'm going to try this again because I believe a lot of people would also love to see this feature.

A svelte component should be able to iterate over child components in <slot />.

Is your feature request related to a problem? Please describe. I constantly get hit by this problem when I want to create a component used for layout purposes. This type of component is solely for creating a specific layout, so it does not care what child component it has. You can think of a relationship between flex container and flex items.

4455 is the issue that comes up when I google for similar issues, but this one assumes that every child is related to the parent component. The solution proposed in this issue was to pass data of children as an array, but in my case, the child can be anything, it gets cumbersome to achieve this.

Describe the solution you'd like I'd like to do something like this. Also, note that the number of children or what children is rendered can be dynamic.

App.svelte

<FluidLayout>
  <Button {...buttonProps1} />
  <Button {...buttonProps2}/>
  <ButtonWithCompletelyDifferentSetOfProps  {...btnProps} />
</FluidLayout>

<FluidLayout>
  <Foo {...fooProps} />
  <Bar {...barProps} />
</FluidLayout>

FluidLayout.svelte

<script>
  import classnames from 'classnames';
  export let wide = false;
</script>

<style lang="scss">
  .fluid-layout {
    /* ... */

    > .fluid-layout__item {
      /* ... */
    }
  }

  .fluid-layout--wide {
    > .fluid-layout__item {
      width: 100%;
      /* ... */
    }
  }
</style>

<div class={classnames("fluid-layout", { ["fluid-layout--wide"]: fluid })}>
  {#each child in slot}
    <div class="fluid-layout__item">
      {child}
    </div>
  {/each}
</div>

Describe alternatives you've considered Solution 1 I could still pass an array of props to the parent component to achieve this.

<FluidLayout childProps={[{
      ...fooProps,
      render: prop => <Foo {...props} />
    }, {
      ...barProps,
      render: props => <Bar {...props} />
    }]}
/>

This should work, but, I think most people could agree that my proposed solution feels a lot more natural.

But, there's one big problem to this. When using TypeScript, you can't get nice type information because every prop can be a completely different type. You'd need to defined a childProps type listing every child component prop, but this can be dynamic.

Solution 2 I can wrap every child component with another layout component (See below). Well, this doesn't work easily because the CSS stylings child component depends on the parent selector (See how fluid-layout--wide is used above to affect the stylings of the child), but component stylings are scoped in Svele. One way to make this work is to make the child selector global, and use that in both parent and child component. But, this makes the code cluttered.

e.g.

<FluidLayout>
  <FluidLayoutItem>
    <Foo />
  <FluidLayoutItem>
  <FluidLayoutItem>
    <Bar />
  <FluidLayoutItem>
</FluidLayout>

I'm aware of solutions like this one, but this doesn't really apply here because I'd need to pass a global class name to each child component.

There are other ways to achieve the same effect, but all of them I've seen seem hacky because Svelte is not expect to support this scenario properly.

How important is this feature to you? Simply having the child components wrapped inside a parent maintains good HTML semantics, and having to pass them via props is counter-intuitive, and it can quickly get cumbersome. It also helps hide the implementation details. Other frameworks such as React support this and I think Svelte should, too.

Having stylings scoped by component is a good thing in general, but it gets in the way when we have a composite component that expects certain styling on multiple levels of DOM elements. My proposed solution looks a lot more like standard HTML and, if I'm not mistaken, I believe Svelte is trying to provide that feel to developers.

I use TypeScirpt and there doesn't seem to have a nice way to achieve this currently. I wouldn't say this is a deal breaker if I'm not using TypeScript, but I won't enjoy writing in Svelte as much if this doesn't get supported. Is there a technical challenge to achieve this or Svelte has been consciously avoiding this? If latter, that seems a bit odd to me.

TheComputerM commented 4 years ago

Usage of getContext and setContext may help to solve your problem. These functions are more powerful than you know.

Using getContext to set index of children

In this you can use getContext to set the index value of children inside slot without using onMount.

Parent.svelte

let startIndex = -1;
setContext('SetIndex', {
  index: () => {
    startIndex += 1;
    return startIndex;
  }
})

Child.svelte

const { index } = getIndex('SetIndex');
value = index();

Now the result would be like:

<Parent>
    <Child /> // has value set as 0
    <Child /> // has value set as 1
    <Child /> // has value set as 2
</Parent>

This is one way to iterate over child components in svelte. But I have no solution for component styles. Hope this helps, this is just an example to help you understand the usage of setContext and getContext.

antony commented 3 years ago

@TheComputerM is correct. You can see the mapbox example on the site to understand how this could work.

The reason that frameworks like React can do this is because they do computation at runtime, vs Svelte where this work is done at compile time. Iterating over the contents of a slot would require a runtime computation, which is not really what Svelte is about.

Svelte however encourages you to hold state and allow Svelte to render based on that state. This means you have a single source of truth (state) and aren't trying to determine state by inspecting the dom.

I think the modification that needs to be done here is how you're using Svelte, rather than an actual modification to Svelte itself.

gnimmelf commented 3 years ago

Sorry for piggybacking here,

looking for solutions to similar problem, and ending up back here regardless of where I start googling =P

The issue everyone has is that the semantics themselves should be sufficient without bothering the children to "register".

So, Iterating over the contents of a slot would require a runtime computation.

Maybe then, if possible:

  1. slots somehow should provide a hook into the runtime (<= displaying my lack of insight here, the point being "just make it work").

  2. As a fan service ;-) Could someone knowledgeably enough provide an example of an "encapsulated" way of solving this while keeping the semantics "pure", and add that as a tutorial? - Regardless of how "hacky" it is =)

Cheers!

Edit: The Mapbox example has children with a specific need from the parent, whereas here the issue is general purpose components like a button or text-inputs, which, say, should be manipulated from the parent container...

Edit2: "Cannot be done"

Xananax commented 3 years ago

I'm making a slider component, which should ideally be able to take any arbitrary child, an image, a container, an SVG component, and so on.

This should be in theory followed by other display components: a grid, a masonry layout, and so a few others. All those layout components should be able to receive any arbitrary children, and have each wrapped with the right child wrapper component and receive the right styles and behavior.

In particular, passing an image should automatically trigger the children wrapper to apply CSS filters, and to open a lightbox on click. If alt text is passed, it can be automatically extracted for the lightbox description, and so on.

I've built such components in other frameworks, and the API can be made exceedingly easy for the consumer.

<script>
import Gallery from 'gallery'
</script>

<Gallery>
  <img src="blah.jpg"/>
  <div>
    <h2>This is a slide</h2>
  </div>
</Gallery>

The API surface becomes completely nullified, and the consumer can just use the component like a native HTML tag, while all the complexity is entirely abstracted away.

Other simple usages may be, for example, a list of todo items that distributes todos on two or more columns depending on the amount of children; or any wrapper with fixed size children needing to grow proportionally (instead of relying on onMount to determine children amount).

In React, where the children object is an arbitrary blob, a Children.map helper is provided to loop over children. Would something like that be achievable in Svelte?

To be clear, I read and understood the arguments about misusing Svelte, and Svelte working differently than React. I feel however that this ease of use, warrants a second look to see if something like that could work with Svelte.

Even if I wanted to do things "the svelte way", I couldn't find any way to fulfill those requirements:

  1. a parent that accepts arbitrary children
  2. styles applied to the children so they work with the parent (preferably on a wrapper element as to not interfere with the child).

The context solution proposed in this issue allows to count children, if the children are prepared to be counted, so it doesn't answer the requirements posed in the opener post (or mine)

Related: https://github.com/sveltejs/svelte/issues/4455

noonien commented 2 years ago

Any update on this? This is very useful when elements need to be moved around in different layouts.

Xananax commented 2 years ago

Workarounds:

(Disclaimer: none of the below is tested or working code. I just tried to provide examples with enough detail to not be trivial, but without all the boilerplate necessary.)


1 - Use pure CSS

Works for simple cases

.slider {
  --height: 400px;
  width: 100%;
  height: var(--height);
  overflow: hidden;
}

.slider-pane {
  display: flex;
  overflow-x: scroll;
  position: relative;
  scroll-behavior: smooth;
  scroll-snap-type: x mandatory;
}

.slider-pane > :global(*) {
  flex-shrink: 0;
  width: 100%;
  height: var(--height);
  scroll-snap-align: center;
}

2 - Use lifeCycle functions

<script>
  import { onMount, afterUpdate } from "svelte"

  let slider;
  const countChildren = () => {
    let totalWidth = 0;
    const navigation = [] 
    for (let i = 0; i < slider.children.length; i++) {
      totalWidth += slider.children[i].offsetWidth;
      navigation.push({index: i, slide: slider.children[i]}
    }
  }

  // get size of static descendants here
  onMount(countChildren);

  // get slot size (and static descendants) here
  afterUpdate(countChildren);
</script>

<div bind:this={slider}>
  <slot />
  {#each navigation as dot (i)}
    <SliderNavigationDot target={dot.slide}/>
  {/dot}
</div>

3 - pass data, rather than elements, to display components

// slider.svelte
<script>
    export let slides;
</script>

<div class="slider">
  {#each slides as slide}
    <Slide><slot {item} /></Slide>
  {/each}
</div>
<script>
  const slides = [
    {alt: "hello", src: "image1.png" },
    {alt: "hello 2", src: "image2.png" },
  ]
</script>
// App.svelte
<Slider slides={slides} let:slides>
  <figure>
      <img {src} {alt}>
      <figcaption>{alt}</figcaption>
  </figure>
</Slider>

4 - Provide different wrapper components at child location

<script>
  import {Slide, Gallery} from './displayComponents'
</script>

<div>
  <Gallery>
    {#each items as item}
      <Gallery.Item src={item.src}/>
    {/each} 
  </Gallery>
  <Slide>
      {#each items as item}
        <Slide.Item src={item.src}/>
      {/each} 
  </Slide>
</div>

None of those solutions satisfy me personally; nonetheless, the workarounds above are workable and should cover nearly all use cases, barring two (that I can think of):

  1. Needing to render custom elements on the server, which doesn't have a dom and no onMount. For example: SVG to PNG, PDF renderers, automated CSS testing, etc (that last one can be worked around with a headless browser)
  2. Generically reusable components, distributed on NPM. They exist, but they are in limited numbers, when compared with other frameworks. This may be due to Svelte being overall less used than giants like React of Vue, but it may also be due to the lack of clear convention. I know it has stopped me personally, at least.

Not a diss on Svelte, which I enjoy using a lot. Any framework will necessarily make a choice of trade-offs.

This said, even more than the lack of access to children, I deplore that there isn't an officially endorsed way; no matter how cumbersome, it'd be alright if there was an accepted convention for all wrapper components. I hope the Svelte team decides to implement at least a documentation page to address this common problem.


note: for future readers, if you didn't read the whole thread, be aware that if your children are not arbitrary, the mapbox example, which uses context, should work for you.

theoephraim commented 2 years ago

Just another vote to add support for this. While the context solution may be helpful for tightly coupled parent-child relationships, iterating over any slot children and inserting additional markup is incredibly useful when building reusable components made for layout.

For example, imagine a <Stack> component (inspired by the Braid design system) that is solely responsible for dealing with the vertical space between its children with an additional setting to insert visual dividers between them. You want to be able to wrap each child with a div that has some class/styling, and insert divider elements (hr/div/etc) between them if necessary.

Using the component looks like:

<Stack spacing="xl" dividers>
  <h1>A title!</h1>
  <div>some stuff...</div>
  <SomeCustomThing />
</Stack>

and the rendered output is something like

<div class="stack stack--spacing-xl">
  <div class="stack__child"><h1>A title!</h1></div>
  <hr class="stack__divider" />
  <div class="stack__child"><div>some stuff...</div>
  <hr class="stack__divider" />
  <div class="stack__child"><div class="custom-thing">the custom thing</div></div>
</div>

While there are good workarounds (or better/sveltier ways) for some cases, there are definitely very valid use cases for this kind of functionality. I have found these kinds of components with very simple DX (rather than needing to also use tightly coupled child components) to be extremely useful.

opack commented 8 months ago

Just another vote to add support for this. While the context solution may be helpful for tightly coupled parent-child relationships, iterating over any slot children and inserting additional markup is incredibly useful when building reusable components made for layout.

For example, imagine a <Stack> component (inspired by the Braid design system) that is solely responsible for dealing with the vertical space between its children with an additional setting to insert visual dividers between them. You want to be able to wrap each child with a div that has some class/styling, and insert divider elements (hr/div/etc) between them if necessary.

Using the component looks like:

<Stack spacing="xl" dividers>
  <h1>A title!</h1>
  <div>some stuff...</div>
  <SomeCustomThing />
</Stack>

and the rendered output is something like

<div class="stack stack--spacing-xl">
  <div class="stack__child"><h1>A title!</h1></div>
  <hr class="stack__divider" />
  <div class="stack__child"><div>some stuff...</div>
  <hr class="stack__divider" />
  <div class="stack__child"><div class="custom-thing">the custom thing</div></div>
</div>

While there are good workarounds (or better/sveltier ways) for some cases, there are definitely very valid use cases for this kind of functionality. I have found these kinds of components with very simple DX (rather than needing to also use tightly coupled child components) to be extremely useful.

I am in this exact situation: I have a component that displays cards, and its sole purpose is displaying a title and arranging the cards inside it, choosing which size and position they should have. I am very disappointed to see there is no "logic" way to do this. The "context" workaround would force me to introduce some logic in the cards that is not expected for the card business; I mean, there is no reason for the cards to have this context/index, except that being able to be laid out by another component they do not know/care about :-( Why can't we just iterate over a slot? Even if it's not possible with a default slot, maybe with a named slot, using $$slots.name?