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 TabLabel
s and TabPanel
s 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.
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.