AvatarListContainer.svelte
1 <script lang="ts"> 2 import type { MastodonAccount } from '$lib/mastodon'; 3 import Avatar from './Avatar.svelte'; 4 import AvatarTooltip from './AvatarTooltip.svelte'; 5 import { 6 groupByLastStatus, 7 groupByCreated, 8 groupByFollowers, 9 groupByServer, 10 groupByMoved 11 } from '$lib/utils/grouping'; 12 import { tick } from 'svelte'; 13 import { page } from '$app/state'; 14 import { friendkit } from '$lib/store.svelte'; 15 import { resolve } from '$app/paths'; 16 import { getFullHandle } from '$lib/mastodon'; 17 import { scale, fade } from 'svelte/transition'; 18 import { createSwarm } from './useSwarm.svelte'; 19 import { accessibility } from '$lib/accessibility.svelte'; 20 import FilterOptOutModal from './FilterOptOutModal.svelte'; 21 22 interface Props { 23 accounts: MastodonAccount[]; 24 groupBy: 'none' | 'last_status' | 'created' | 'followers' | 'server' | 'moved'; 25 sortDirection?: 'asc' | 'desc'; 26 currentView: string; 27 emptyMessage?: string; 28 } 29 30 let { 31 accounts, 32 groupBy, 33 sortDirection = 'desc', 34 currentView, 35 emptyMessage = 'No accounts found.' 36 }: Props = $props(); 37 38 // ------------------------------------------------------------------------- 39 // Accessibility 40 // ------------------------------------------------------------------------- 41 const isReducedMotion = $derived.by( 42 () => accessibility.prefersReducedMotion || friendkit.reducedMotion 43 ); 44 45 // ------------------------------------------------------------------------- 46 // Swarm Engine 47 // ------------------------------------------------------------------------- 48 const swarm = createSwarm({ 49 getAccounts: () => accounts, 50 getPrivacyBlur: () => friendkit.privacyBlur 51 }); 52 53 let containerEl = $state<HTMLElement>(); 54 $effect(() => { 55 swarm.containerEl = containerEl; 56 }); 57 58 // Re-sync whenever accounts or layout-affecting props change. 59 $effect(() => { 60 void accounts; 61 void groupBy; 62 void sortDirection; 63 void swarm.avatarSize; 64 void friendkit.privacyBlur; 65 66 tick().then(() => swarm.sync()); 67 }); 68 69 // ------------------------------------------------------------------------- 70 // Grouping 71 // ------------------------------------------------------------------------- 72 const groups = $derived.by(() => { 73 if (groupBy === 'last_status') return groupByLastStatus(accounts, sortDirection); 74 if (groupBy === 'created') return groupByCreated(accounts, sortDirection); 75 if (groupBy === 'followers') return groupByFollowers(accounts, sortDirection); 76 if (groupBy === 'server') return groupByServer(accounts, sortDirection); 77 if (groupBy === 'moved') return groupByMoved(accounts, sortDirection); 78 return null; 79 }); 80 81 // ------------------------------------------------------------------------- 82 // Tooltip state 83 // ------------------------------------------------------------------------- 84 let tooltipAccount = $state<MastodonAccount | null>(null); 85 let tooltipX = $state(0); 86 let tooltipY = $state(0); 87 let tooltipVisible = $state(false); 88 89 function handlePlaceholderEnter(account: MastodonAccount, el: HTMLElement) { 90 const rect = el.getBoundingClientRect(); 91 tooltipX = rect.left + rect.width / 2; 92 tooltipY = rect.top - 10; 93 tooltipAccount = account; 94 tooltipVisible = true; 95 } 96 97 // ------------------------------------------------------------------------- 98 // Filter Opt-out Modal state 99 // ------------------------------------------------------------------------- 100 let restrictedAccount = $state<MastodonAccount | null>(null); 101 102 function handleAccountClick(e: MouseEvent, account: MastodonAccount) { 103 if ((account as any)._filterRestricted) { 104 e.preventDefault(); 105 restrictedAccount = account; 106 } 107 } 108 109 function getAccountUrl(account: MastodonAccount) { 110 if ((account as any)._filterRestricted) return '#'; 111 return resolve( 112 `/${currentView}/${getFullHandle(account, friendkit.instance)}${page.url.search}` 113 ); 114 } 115 </script> 116 117 <!-- 118 The container is `position:relative` so absolutely-positioned display icons 119 are placed relative to it. Placeholder <a> elements are invisible but live 120 in normal flow — they define the layout, handle keyboard navigation, and fire 121 hover events for the tooltip. Display icons are aria-hidden + pointer-events:none. 122 --> 123 <div class="fk-avatar-list" bind:this={containerEl}> 124 {#if accounts.length === 0} 125 <p class="fk-avatar-list__empty">{emptyMessage}</p> 126 {:else if groups === null} 127 <!-- Flat (ungrouped) grid — used for Name sort --> 128 <ul class="fk-avatar-list__grid"> 129 {#each accounts as account (account.id)} 130 <li> 131 <a 132 use:swarm.registerPlaceholder={account.id} 133 href={getAccountUrl(account)} 134 class="fk-avatar-list__placeholder" 135 onmouseenter={(e: MouseEvent) => 136 handlePlaceholderEnter(account, e.currentTarget as HTMLElement)} 137 onmouseleave={() => (tooltipVisible = false)} 138 onclick={(e) => handleAccountClick(e, account)} 139 aria-label={(account as any)._filterRestricted 140 ? 'Restricted filter result' 141 : account.display_name || account.username} 142 ></a> 143 </li> 144 {/each} 145 </ul> 146 {:else} 147 <!-- Grouped sections --> 148 {#each groups as group (group.label)} 149 <section class="fk-avatar-list__section"> 150 <h3 class="fk-avatar-list__section-label">{group.label}</h3> 151 <ul class="fk-avatar-list__grid"> 152 {#each group.accounts as account (account.id)} 153 <li> 154 <a 155 use:swarm.registerPlaceholder={account.id} 156 href={getAccountUrl(account)} 157 class="fk-avatar-list__placeholder" 158 onmouseenter={(e: MouseEvent) => 159 handlePlaceholderEnter(account, e.currentTarget as HTMLElement)} 160 onmouseleave={() => (tooltipVisible = false)} 161 onclick={(e) => handleAccountClick(e, account)} 162 aria-label={(account as any)._filterRestricted 163 ? 'Restricted filter result' 164 : account.display_name || account.username} 165 ></a> 166 </li> 167 {/each} 168 </ul> 169 </section> 170 {/each} 171 {/if} 172 173 <!-- Display layer: absolutely-positioned avatar images. --> 174 {#each swarm.icons.values() as icon, i (icon.account.id)} 175 {#if icon.inViewport || icon.exiting} 176 {#if isReducedMotion} 177 <div 178 transition:fade={{ duration: 400 }} 179 class="fk-avatar-list__display-item" 180 style="translate: {icon.x}px {icon.y}px;" 181 > 182 <Avatar 183 src={icon.account.avatar} 184 size={swarm.avatarSize} 185 moved={!!icon.account.moved} 186 blur={icon.blur} 187 hue={(i * 137) % 360} 188 restricted={(icon.account as any)._filterRestricted} 189 class="fk-avatar-list__display-icon {icon.visible && !icon.exiting 190 ? 'fk-avatar-list__display-icon--visible' 191 : ''}" 192 aria-hidden="true" 193 /> 194 </div> 195 {:else} 196 <div 197 transition:scale={{ duration: 400, start: 0.4, delay: (i % 20) * 25 }} 198 class="fk-avatar-list__display-item" 199 style="translate: {icon.x}px {icon.y}px;" 200 > 201 <Avatar 202 src={icon.account.avatar} 203 size={swarm.avatarSize} 204 moved={!!icon.account.moved} 205 blur={icon.blur} 206 hue={(i * 137) % 360} 207 restricted={(icon.account as any)._filterRestricted} 208 seed={icon.account.id} 209 class="fk-avatar-list__display-icon {icon.visible && !icon.exiting 210 ? 'fk-avatar-list__display-icon--visible' 211 : ''}" 212 aria-hidden="true" 213 /> 214 </div> 215 {/if} 216 {/if} 217 {/each} 218 219 <AvatarTooltip account={tooltipAccount} x={tooltipX} y={tooltipY} bind:visible={tooltipVisible} /> 220 221 {#if restrictedAccount} 222 <FilterOptOutModal account={restrictedAccount} onClose={() => (restrictedAccount = null)} /> 223 {/if} 224 </div> 225 226 <style> 227 .fk-avatar-list { 228 position: relative; 229 margin-top: 0; 230 display: flex; 231 flex-direction: column; 232 gap: 0; 233 /* Allow tooltips + exiting icons to overflow the border. */ 234 overflow: visible; 235 236 --grid-gap: 1.5rem; 237 --avatar-size: 80px; 238 } 239 240 @media (max-width: 640px) { 241 .fk-avatar-list { 242 --grid-gap: 1rem; 243 --avatar-size: calc((100% - (3 * var(--grid-gap))) / 4); 244 } 245 } 246 247 .fk-avatar-list__empty { 248 text-align: center; 249 padding: 3rem; 250 opacity: 0.5; 251 } 252 253 .fk-avatar-list__placeholder { 254 display: block; 255 width: 100%; 256 aspect-ratio: 1 / 1; 257 opacity: 0; 258 border-radius: 50%; 259 -webkit-tap-highlight-color: transparent; 260 cursor: pointer; 261 } 262 263 .fk-avatar-list__grid li { 264 width: var(--avatar-size); 265 aspect-ratio: 1 / 1; 266 flex-shrink: 0; 267 } 268 269 /* Make focus ring visible for keyboard users. */ 270 .fk-avatar-list__placeholder:focus-visible { 271 opacity: 1; 272 outline: 2px solid var(--akui-bg-accent); 273 outline-offset: 3px; 274 } 275 276 .fk-avatar-list__grid { 277 display: flex; 278 flex-wrap: wrap; 279 align-items: flex-start; 280 gap: var(--grid-gap); 281 list-style: none; 282 padding: 0; 283 margin: 0; 284 } 285 286 /* Desktop grid - auto-fill based on avatar size */ 287 @media (min-width: 640px) { 288 .fk-avatar-list__grid { 289 grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); 290 } 291 } 292 293 /* Flat grid */ 294 .fk-avatar-list > .fk-avatar-list__grid { 295 justify-content: center; 296 } 297 298 /* Grouped section */ 299 .fk-avatar-list__section { 300 padding: 1.5rem 0 2rem; 301 } 302 303 .fk-avatar-list__section-label { 304 font-size: 0.7rem; 305 font-weight: 700; 306 letter-spacing: 0.1em; 307 text-transform: uppercase; 308 opacity: 0.45; 309 margin: 0 0 1.25rem; 310 color: var(--akui-fg); 311 padding-bottom: 0.5rem; 312 border-bottom: 1px solid rgba(var(--akui-fg-rgb), 0.08); 313 } 314 315 .fk-avatar-list__display-item { 316 position: absolute !important; 317 top: 0; 318 left: 0; 319 pointer-events: none; 320 will-change: translate; 321 transition: translate 0.35s cubic-bezier(0.4, 0, 0.2, 1); 322 } 323 324 /* Disable movement transitions for reduced motion. Opacity is kept as per user request. */ 325 :global([data-reduced-motion='true']) .fk-avatar-list__display-item { 326 transition: none !important; 327 } 328 329 :global(.fk-avatar-list__display-icon) { 330 pointer-events: none; 331 /* box-shadow is significantly faster for the compositor to cache than drop-shadow filters */ 332 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); 333 border-radius: 50%; 334 335 /* We rely on Svelte transitions for entrance/exit; CSS transitions here 336 caused "layer flapping" and redundant Animation events in the trace. */ 337 will-change: opacity, transform; 338 339 /* Default (not visible): scaled down + transparent */ 340 opacity: 0; 341 scale: 0.6; 342 transition: 343 opacity 0.3s ease, 344 scale 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); 345 } 346 347 /* Disable scale transitions for reduced motion, keeping opacity. */ 348 :global([data-reduced-motion='true']) :global(.fk-avatar-list__display-icon) { 349 transition: opacity 0.3s ease !important; 350 scale: 1 !important; 351 } 352 353 :global(.fk-avatar-list__display-icon--visible) { 354 opacity: 1 !important; 355 scale: 1 !important; 356 } 357 </style>