/ src / lib / components / section-skeleton / section-skeleton.svelte
section-skeleton.svelte
  1  <script lang="ts">
  2    import Emoji from '$lib/components/emoji/emoji.svelte';
  3    import { fade, scale } from 'svelte/transition';
  4    import Spinner from '../spinner/spinner.svelte';
  5    import PaddedHorizontalScroll from '../padded-horizontal-scroll/padded-horizontal-scroll.svelte';
  6    import TransitionedHeight from '../transitioned-height/transitioned-height.svelte';
  7    import EmptyState from './empty-state.svelte';
  8    import DisconnectedState from './disconnected-state.svelte';
  9    import type { ComponentProps } from 'svelte';
 10    import Button from '../button/button.svelte';
 11  
 12    let highlit = $state(false);
 13  
 14    export const highlightSection = () => {
 15      highlit = true;
 16      setTimeout(() => {
 17        highlit = false;
 18      }, 500);
 19    };
 20  
 21    interface Props {
 22      loaded?: boolean;
 23      empty?: boolean;
 24      disconnected?: boolean;
 25      error?: boolean;
 26      placeholderOutline?: boolean;
 27      horizontalScroll?: boolean;
 28      overflowAction?: (ComponentProps<typeof Button> & { label: string }) | undefined;
 29      collapsed?: boolean;
 30      emptyStateEmoji?: string;
 31      emptyStateHeadline?: string | undefined;
 32      emptyStateText?: string | undefined;
 33      disconnectedStateEmoji?: string | undefined;
 34      disconnectedStateHeadline?: string | undefined;
 35      disconnectedStateText?: string | undefined;
 36      children?: import('svelte').Snippet;
 37    }
 38  
 39    let {
 40      loaded = false,
 41      empty = false,
 42      disconnected = false,
 43      error = false,
 44      placeholderOutline = true,
 45      horizontalScroll = true,
 46      overflowAction = undefined,
 47      collapsed = $bindable(false),
 48      emptyStateEmoji = '👻',
 49      emptyStateHeadline = 'Nothing to see here',
 50      emptyStateText = undefined,
 51      disconnectedStateEmoji = '🫙',
 52      disconnectedStateHeadline = 'You are disconnected',
 53      disconnectedStateText = undefined,
 54      children,
 55    }: Props = $props();
 56  
 57    let placeholderContainerElem: HTMLDivElement | undefined = $state();
 58  
 59    let contentTransitonedIn = $state(loaded);
 60  </script>
 61  
 62  <div class="section-skeleton">
 63    <TransitionedHeight transitionHeightChanges={!contentTransitonedIn} bind:collapsed>
 64      <div class="inner-wrapper">
 65        {#if !loaded || error || empty}
 66          <div
 67            out:fade={{ duration: 250 }}
 68            class="placeholder-container"
 69            bind:this={placeholderContainerElem}
 70            style:border={placeholderOutline ? '1px solid var(--color-foreground-level-3)' : ''}
 71          >
 72            {#if !loaded}
 73              <Spinner />
 74            {:else if error}
 75              <div class="notice" in:fade={{ duration: 250 }}>
 76                <Emoji emoji="⚠️" size="huge" />
 77                <div class="text-group">
 78                  <p class="typo-text-small-bold">Oops, something went wrong.</p>
 79                  <p class="typo-text-small">
 80                    Sorry, we werenʼt able to load this. There may be more information in the
 81                    developer console.
 82                  </p>
 83                  <a
 84                    class="typo-link typo-text-small"
 85                    target="_blank"
 86                    href="https://discord.gg/BakDKKDpHF">Ask for help</a
 87                  >
 88                </div>
 89              </div>
 90            {:else if disconnected}
 91              <DisconnectedState
 92                emoji={disconnectedStateEmoji}
 93                headline={disconnectedStateHeadline}
 94                text={disconnectedStateText}
 95              />
 96            {:else if empty}
 97              <!-- Empty state -->
 98              <EmptyState
 99                emoji={emptyStateEmoji}
100                headline={emptyStateHeadline}
101                text={emptyStateText}
102              />
103            {/if}
104          </div>
105        {:else}
106          <!-- Actual content -->
107          <!--
108            Applying a negative margin matching the height of `placeholder-container` while it's still in the DOM
109            to prevent an ugly transition glitch.
110          -->
111          <div
112            class="content-container"
113            style:margin-top={placeholderContainerElem ? '-16rem' : undefined}
114            in:fade={{ duration: 250 }}
115            ontransitionend={() => {
116              contentTransitonedIn = true;
117            }}
118          >
119            {#if horizontalScroll}
120              <PaddedHorizontalScroll>
121                {@render children?.()}
122              </PaddedHorizontalScroll>
123            {:else}
124              {@render children?.()}
125            {/if}
126          </div>
127  
128          {#if overflowAction}
129            <div class="overflow-action">
130              <div style:pointer-events="auto">
131                <Button {...overflowAction}>{overflowAction.label}</Button>
132              </div>
133            </div>
134          {/if}
135        {/if}
136      </div>
137    </TransitionedHeight>
138    {#if highlit}
139      <div
140        in:scale={{ duration: 300, start: 0.9 }}
141        out:scale={{ duration: 300, start: 1.05 }}
142        class="highlight-overlay"
143      ></div>
144    {/if}
145  </div>
146  
147  <style>
148    .section-skeleton {
149      position: relative;
150      margin: 0 -2.5rem;
151      height: fit-content;
152    }
153  
154    .section-skeleton .inner-wrapper {
155      padding: 0 2.5rem;
156    }
157  
158    .placeholder-container {
159      width: 100%;
160      border-radius: 1rem 0 1rem 1rem;
161      display: flex;
162      justify-content: center;
163      align-items: center;
164      color: var(--color-foreground);
165      height: 16rem;
166    }
167  
168    .notice {
169      display: flex;
170      flex-direction: column;
171      gap: 1rem;
172      max-width: 16rem;
173      text-align: center;
174    }
175  
176    .text-group {
177      display: flex;
178      flex-direction: column;
179      gap: 0.5rem;
180    }
181  
182    .highlight-overlay {
183      position: absolute;
184      top: -0.5rem;
185      left: 2rem;
186      right: 2rem;
187      bottom: -0.5rem;
188      background-color: var(--color-primary);
189      opacity: 0.2;
190      pointer-events: none;
191      z-index: 1;
192      border-radius: 1.25rem 0 1.25rem 1.25rem;
193    }
194  
195    .overflow-action {
196      pointer-events: none;
197      position: absolute;
198      left: 2.5rem;
199      right: 2.5rem;
200      bottom: -1rem;
201      height: 8rem;
202      display: flex;
203      justify-content: center;
204      align-items: flex-end;
205      background: linear-gradient(
206        to bottom,
207        rgba(255, 255, 255, 0),
208        var(--color-background) 79%,
209        var(--color-background)
210      );
211    }
212  
213    @media (max-width: 577px) {
214      .section-skeleton {
215        margin: 0 -1rem;
216      }
217  
218      .section-skeleton .inner-wrapper {
219        padding: 0 1rem;
220      }
221  
222      .overflow-action {
223        left: 1rem;
224        right: 1rem;
225      }
226    }
227  </style>