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>