0

I'm writing a small app using Svelte that presents a list of collapsible panels with text to the user, has them select a set of those panels, and, when the user clicks a button, groups these panels into another collapsible panel in a different list. Conceptually, it's the deferred transitions tutorial with an extra layer on one end.

I have prepared a REPL so you can follow along. Replace the component in App.svelte to switch the implementations.

The Original.svelte component illustrates the problem:
I'm using Svelte's crossfade transition to send and receive, and everything looks great when the grouped panels are collapsed. Any open panel, however, will warp awkwardly to its closed state when sent between the lists, which I understand is an effect of crossfade. The solution is obvious: close the panels first, then send them over to their target.

My question is, now, what is the idiomatic/optimal way to do that in Svelte?

The WithDelay.svelte component shows my attempt using delay on the individual panel transitions based on whether a panel was open. As is evident, this delays the send/receive transitions but still warps the panels, no matter how long the delay is (try a second or longer).

My second intuition was to collect the panels to be moved in an intermediate list, close them, and finally use the transition events on:outrostart/on:outroend to finalize the move. However, the logic was too lengthy to be correct, requiring multiple helper functions and extra arrays to track the elements. Once multiple panels had their events fire simultaneously, everything just went haywire. Preventing race conditions turned out so complicated that I scrapped that attempt entirely.

My third attempt can be seen in WithTimeout.svelte, where I resorted to using setTimeout to delay the actual moving of elements after the panels have been closed. As expected, that works exactly how it should and should be used as a reference for what I want to achieve, but using setTimeout just feels wrong. Still, I feel like I should not need to implement my own wait function using requestAnimationFrame since Svelte itself seems to have no built-in function for that. Internally, it runs the transition in a loop construct that uses requestAnimationFrame.

I'd go on with the timeout, but I would like to hear more experienced opinions before proceeding. I'm not even sure if my concerns are even justified. Maybe requestAnimationFrame only matters when doing actual transition stuff, and setTimeout is fine for assigning variables? Thanks a lot for your help!

TheKvist
  • 583
  • 5
  • 16

1 Answers1

1

A different approach could be to handle the closing inside the component via an exported function that returns a Promise that resolves when the out transition has ended.

REPL

Panel.svelte
<script lang="ts">

    ...

    let resCb = () => {}

    export function close() {
        return new Promise(res => {
            if(open) {
                resCb = res
                open = false                
            }else {
                res()
            }
        })
    }

    function handleOutroEnd() {
        resCb()
    }

</script>

...

    {#if open}
    <div class="panel-content"
             in:slide|local="{{ duration: slideDuration }}"
             out:slide|local="{{ duration: slideDuration }}" 
             on:outroend={handleOutroEnd}            
             >
        <slot />
    </div>
    {/if}

(The |local flag added so that the slide transition doesn't play when the panels are added as slot to the Panel component for the group. Setting up an extra component for the group might be better.)

The function can be called on the component references that can be organised on an object with either the panel.id as key or a combination with the group.key (crypto.randomUUID() instead of Symbol())

<script>
    
    ...

    const panelRefs = {}

    function panelRefKey(panel, group) {
        const key = (group.key ?? '') + panel.id
        return key
    }

</script>


                <Panel header={panel.header}
                             bind:selected={panel.selected}
                             bind:this={panelRefs[panel.id]}
                             >
                    {panel.content}
                </Panel>
            
                ...

                            <Panel header={panel.header}
                                         bind:this="{panelRefs[panelRefKey(panel, group)]}"
                                         onDelete={() => deletePanel(group, panel)}
                                >
                                {panel.content}
                            </Panel>

Since close() directly resolves if the component wasn't opened, tracking bind:open on the data/component is redundant and the function can simply be called on all component references before the data changes sides.

    async function deleteGroup(group) {     
        const groupEntries = Object.entries(panelRefs).filter(([key, _]) => key.startsWith(group.key))
        const panelsToClose = groupEntries.filter(([_, panelRef])=> panelRef && panelRef.close).map(([_, panelRef]) => panelRef)

        await Promise.all(panelsToClose.map(panelRef => panelRef.close()))
        groupEntries.forEach(([key, _]) => delete panelRefs[key])

        ...
    }

    async function deletePanel(group, panel) {
        const key = panelRefKey(panel, group)
        await panelRefs[key].close()

        ...
    }
Corrl
  • 6,206
  • 1
  • 10
  • 36
  • Fantastic! A solution using `outroend` was exactly what I was hoping for, as I was convinced this would be the way to go, but didn't think at all about using refs. I also had a similar construct to your `resCb` already, but then got just completely confused. Cheers! – TheKvist May 26 '23 at 17:59