Search code examples
sveltesvelte-3svelte-component

Svelte: access and manipulate content passed to a slot


I'm trying to create a reusable <Tabs> component. Here's what I want the API to be:

<Tabs>
  <Tab>
    <TabLabel>First</TabLabel>
    <TabPanel>First tab's content</TabPanel>
  </Tab>

  <Tab>
    <TabLabel>Second</TabLabel>
    <TabPanel>Second tab's content</TabPanel>
  </Tab>
</Tabs>

And here's the HTML output I'd expect:

<div>
  <div>
    <div>First</div>
    <div>Second</div>
  </div>

  <div>
    <div>First tab's content</div>
    <div>Second tab's content</div>
  </div>
</div>

Notice how the content gets rearranged: all the TabLabels and TabPanels get grouped together. I wonder if this kind of rearrangement would be possible in Svelte?

I pondered possible solutions, and I think it would need to happen at the TabView level, but don't know how to actually access content passed to slot to manipulate it.


Solution

  • It is certainly not easy, but it is possible.

    Whenever you have components that need to do things based on other components around them, you can use setContex and getContext. Then, you set a store as the context and pass bounded <div>s upwards. Finally, you can hide the original and rebuild it using the @html tag (to keep it declarative and not have to use any DOM APIs)

    Below I have my implementation with some notes. I did it with Typescript, but you can remove all the type annotations if you are not using it. This works with any HTML btw, not just text.

    Tabs.svelte:

    Here we set the context store and build the DOM structure.

    <script lang="ts">
        import { setContext } from "svelte";
        import { writable } from "svelte/store";
    
        const tabs = writable({} as { [label: string]: HTMLDivElement })
    
        setContext('tabs', tabs)
    
        $: console.log($tabs);
        
    </script>
    
    <div>
        <div>
    
            {#each Object.keys($tabs) as label}
                <div>
                    {@html label}
                </div>
            {/each}
        </div>
            
        <div>
            {#each Object.values($tabs) as panel}
                <div>
                    {@html panel?.innerHTML}
                </div>
            {/each}
        </div>
    </div>
        
    <div style="visibility: hidden;">
        <slot />
    </div>
    

    Tab.svelte:

    <script lang="ts">
        import { getContext, setContext } from "svelte";
        import { writable, type Writable } from "svelte/store";
    
        const tabLabel = writable<HTMLDivElement>(null)
        const tabPanel = writable<HTMLDivElement>(null)
    
        setContext('label', tabLabel)
        setContext('panel', tabPanel)
    
        const tabs = getContext('tabs') as Writable<{ [label: string]: HTMLDivElement }[]>
    
        $: if ($tabLabel) {
            $tabs[$tabLabel.innerHTML] = $tabPanel
        } 
    </script>
    
    <slot />
    

    TabLabel.svelte

    <script lang="ts">
        import { getContext } from "svelte";
        import type { Writable } from "svelte/store";
    
        let slot: HTMLDivElement
        const tabLabelStore = getContext('label') as Writable<HTMLDivElement>
        $: $tabLabelStore = slot
    </script>
    
    <div bind:this={slot}>
        <slot />
    </div>
    

    TabPanel.svelte

    <script lang="ts">
        import { getContext } from "svelte";
        import type { Writable } from "svelte/store";
    
        let slot: HTMLDivElement
        const tabPanelStore = getContext('panel') as Writable<HTMLDivElement>
        $: $tabPanelStore = slot
    </script>
    
    <div bind:this={slot}>
        <slot />
    </div>
    

    Note: since I'm using .innerHTML the <div>s that are wrapping the <slot/>s are not a problem, in case you wondered.

    It does work, but, even though I've spent a considerable amount of time building this demo, I would encourage against using this. It has too much magic and it is kind of finicky.