Search code examples
sveltesvelte-3svelte-component

How to modifying child component's slots from parent components in Svelte


I am developing a DataTable component in Svelte with this usage:

<script>
    import DataTable from './DataTable.svelte'
    import Column from './Column.svelte'
    
    let items = [
        {id: 1, name: 'Name 1', age: 23},
        {id: 2, name: 'Name 2', age: 28},
        {id: 3, name: 'Name 3', age: 35},
        {id: 4, name: 'Name 4', age: 18},
    ] 
</script>

<DataTable {items} let:item>
        <Column title="Id">{item.id}</Column>
        <Column title="Customer">{item.name}</Column>
        <Column title="Age">{item.age}</Column>
</DataTable>

The Column component just works as a meta definition for DataTable component. There are some other ways to define columns in script section (like this), but in order to be more focused on business logic in script section, I'm trying to reduce component's visual definitions/configurations in the script section.

Therefore, the default slot content in Column component should be passed to parent DataTable component and then rendered in the cells. In other words, parent component should take control of child component's slots.

DataTable.svelte:

<script>
    import { setContext, getContext, onDestroy } from 'svelte';
    
    export let items = []
    
    let columns = []
    
    setContext('columns', {
        register: column => {
            columns.push(column)
            
            onDestroy(() => {
                const i = columns.indexOf(column)
                columns.splice(i, 1)
            })
        },
        getColumns: () => {return columns}
    })
</script>
<slot />
{#if items}
    <table width="100%" border="1">
        <thead>
            <tr>
                {#each columns as column}
                    <th>
                        {column.title}
                    </th>
                {/each}
            </tr>
        </thead>
        {#each items as item1, i}
            <tr>
                {#each columns as column}
                    <td>
                        {JSON.stringify(column.$$scope.ctx[0][i])}
                    </td>
                {/each}
            </tr>
        {/each}
    </table>
{/if}

Column.svelte:

<script>
    import { getContext } from 'svelte';
    let columnProps = {...$$props}
    const { register } = getContext('columns')
    register(columnProps) 
</script>

Here is the repl.

In this line: {JSON.stringify(column.$$scope.ctx[0][i])} the bound row is shown correctly (not sure if it works in all cases because index 0 is hardcoded!).

But, the content should be rendered using the first, second, etc. column's slots ({item.id},{item.name}, etc.)

How can I render slot dynamically or in the parent component to show the correct slot content?

Also, as slot value passed to Column in the above usage (without any slot inside the child), Svelte raises warning (<Column> received an unexpected slot "default".) which is completely correct!

What is the correct way of implementing this DataTable component?


Solution

  • The correct way of doing this is probably to not do it like this at all. I worked on implementing a tab control in a similar fashion and Svelte seems to be not so great at interacting with slots/child components that are used in multiple places.

    To make this work to some degree you can apply a few changes:

    1. Render the cells in Column:
    <td>
        <slot />
    </td>
    
    1. Render the entire row by inserting the slot in DataTable (it already contains all columns):
    {#each items as item, i}
        <tr>
            <slot {item} />
        </tr>
    {/each}
    
    1. Remove that rogue <slot/> in DataTable after the <script> tag which will break since it has no item.

    2. Reassign columns, since it won't update otherwise; also check whether the column has been registered yet, otherwise you get duplicates (one for each row):

        register: column => {
            if (columns.find(c => c.title == column.title))
                return;
    
            columns = [...columns, column];
            
            onDestroy(() => {
                columns = columns.filter(c => c.title != column.title);
            })
        },
    

    REPL

    You will probably regret this once you figure that the title should be arbitrary DOM content and not just a string.


    You should definitely not access something like $$scope.ctx, that is an implementation detail that can change at any point.