Animate library tabs, rows, editor track lists, status badges and dropzone

Tabs get a sliding indicator that persists across library routes, grid rows flip and collapse on removal, the selection bar and filters crossfade, album tracks move between lists with a crossfade, processing badges pulse and the dropzone scales while dragging.
This commit is contained in:
2026-09-25 01:23:40 +02:00
parent ebe9769d4e
commit 43928ba9b9
15 changed files with 137 additions and 40 deletions
@@ -4,6 +4,7 @@
import X from '@lucide/svelte/icons/x';
import type { LibraryTrack } from '$lib/types';
import { plural } from '$lib/utils/format';
import { transferBetweenLists } from '$lib/utils/transitions';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import TrackPickList from './TrackPickList.svelte';
@@ -14,6 +15,8 @@
let { tracks, memberIds }: Props = $props();
const [send, receive] = transferBetweenLists();
let currentIds = $state<string[]>(untrack(() => [...memberIds]));
const initialIds = untrack(() => new Set(memberIds));
@@ -43,13 +46,13 @@
{#if available.length > 0}
<h3 class="mt-6 mb-2 px-3 text-eyebrow text-fg-2">Añadir desde tu biblioteca</h3>
<TrackPickList tracks={available} icon={Plus} onPick={add} />
<TrackPickList tracks={available} icon={Plus} {send} {receive} onPick={add} />
{/if}
<div class="mt-6">
{#if inAlbum.length > 0}
<h3 class="mb-2 px-3 text-eyebrow text-fg-2">En este álbum</h3>
<TrackPickList tracks={inAlbum} icon={X} onPick={remove} />
<TrackPickList tracks={inAlbum} icon={X} {send} {receive} onPick={remove} />
{:else}
<EmptyState
title="Este álbum aún no tiene canciones"
@@ -1,4 +1,9 @@
<script lang="ts" module>
const FADE = { duration: 200 };
</script>
<script lang="ts">
import { fade } from 'svelte/transition';
import Upload from '@lucide/svelte/icons/upload';
import Music from '@lucide/svelte/icons/music';
import SearchX from '@lucide/svelte/icons/search-x';
@@ -64,25 +69,32 @@
{/if}
{#if tracks.length > 0}
{#if selectedIds.length > 0}
<SelectionBar
class="mt-4"
label={plural(selectedIds.length, 'seleccionada', 'seleccionadas')}
onClear={() => (selectedIds = [])}
>
<Button size="sm" variant="danger" onclick={deleteSelected}>Borrar</Button>
</SelectionBar>
{:else}
<div class="mt-4 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3">
<SearchInput
bind:value={query}
placeholder="Filtrar por título o artista"
size="sm"
class="w-full sm:max-w-search sm:flex-1"
/>
<ChipGroup label="Filtrar por estado" options={filterOptions} bind:value={filter} />
</div>
{/if}
<div class="mt-4 grid">
{#if selectedIds.length > 0}
<div class="col-start-1 row-start-1" in:fade={FADE} out:fade={FADE}>
<SelectionBar
label={plural(selectedIds.length, 'seleccionada', 'seleccionadas')}
onClear={() => (selectedIds = [])}
>
<Button size="sm" variant="danger" onclick={deleteSelected}>Borrar</Button>
</SelectionBar>
</div>
{:else}
<div
class="col-start-1 row-start-1 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3"
in:fade={FADE}
out:fade={FADE}
>
<SearchInput
bind:value={query}
placeholder="Filtrar por título o artista"
size="sm"
class="w-full sm:max-w-search sm:flex-1"
/>
<ChipGroup label="Filtrar por estado" options={filterOptions} bind:value={filter} />
</div>
{/if}
</div>
{#if visible.length > 0}
<ManageTrackList
@@ -62,6 +62,7 @@
columns={COLUMNS}
rows={tracks}
rowKey={(track) => track.id}
stagger
rowClass={(track) => (player.currentId === track.id ? 'surface-active' : 'hover:bg-hover')}
>
{#snippet head()}
@@ -1,15 +1,22 @@
<script lang="ts">
import type { LucideIcon } from '@lucide/svelte';
import { flip } from 'svelte/animate';
import { expoOut } from 'svelte/easing';
import type { LibraryTrack } from '$lib/types';
import type { transferBetweenLists } from '$lib/utils/transitions';
import ListRow from '$lib/components/ui/media/ListRow.svelte';
type Transfer = ReturnType<typeof transferBetweenLists>;
interface Props {
tracks: LibraryTrack[];
icon: LucideIcon;
send: Transfer[0];
receive: Transfer[1];
onPick: (track: LibraryTrack) => void;
}
let { tracks, icon: Icon, onPick }: Props = $props();
let { tracks, icon: Icon, send, receive, onPick }: Props = $props();
function subtitle(track: LibraryTrack): string {
const album = track.albumTitle ? `en ${track.albumTitle}` : 'Sin álbum';
@@ -19,7 +26,12 @@
<div class="flex flex-col gap-px" role="list">
{#each tracks as track (track.id)}
<div role="listitem">
<div
role="listitem"
animate:flip={{ duration: 200, easing: expoOut }}
in:receive|global={{ key: track.id }}
out:send|global={{ key: track.id }}
>
<ListRow
size="xs"
trackId={track.id}