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
+1
View File
@@ -15,6 +15,7 @@ const ARBITRARY_WHITELIST = [
/^my-\[\d+d?vh\]$/, // +error.svelte: centrado vertical de página de error
/^py-\[\d+d?vh\]$/, // Explorar: centrado vertical de "sin resultados"
/^transition-\[filter\]$/, // Explorar: genre-tile hover
/^transition-\[transform,width\]$/, // Tabs: indicador deslizante
/^bg-\[image:var\(--mf-[\w-]+\)\]$/
];
@@ -13,4 +13,9 @@
const info = $derived(TRACK_STATUS[status]);
</script>
<StatusBadge label={info.label} tone={info.tone} class={klass} />
<StatusBadge
label={info.label}
tone={info.tone}
pulse={status === 'Processing' || status === 'Pending'}
class={klass}
/>
@@ -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,16 +69,22 @@
{/if}
{#if tracks.length > 0}
<div class="mt-4 grid">
{#if selectedIds.length > 0}
<div class="col-start-1 row-start-1" in:fade={FADE} out:fade={FADE}>
<SelectionBar
class="mt-4"
label={plural(selectedIds.length, 'seleccionada', 'seleccionadas')}
onClear={() => (selectedIds = [])}
>
<Button size="sm" variant="danger" onclick={deleteSelected}>Borrar</Button>
</SelectionBar>
</div>
{:else}
<div class="mt-4 flex min-h-11 flex-wrap items-center gap-x-4 gap-y-3">
<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"
@@ -83,6 +94,7 @@
<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}
@@ -71,7 +71,7 @@
ondragleave={onLeave}
ondrop={onDrop}
class="flex min-h-dropzone flex-col items-center justify-center rounded-panel border border-dashed p-6 text-center transition sm:p-10 {dragging
? 'border-accent/60 bg-accent-tint'
? 'scale-101 border-accent/60 bg-accent-tint'
: 'border-line-strong bg-surface'} {klass}"
>
<span class="grid size-14 place-items-center rounded-full bg-surface-2 text-fg-2">
@@ -4,6 +4,9 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { expoOut } from 'svelte/easing';
import { collapse } from '$lib/utils/transitions';
import { gridColumns, type GridColumn } from '$lib/utils/gridColumns';
interface Props {
@@ -15,6 +18,7 @@
rowClass?: (row: T, index: number) => string;
oncontextmenu?: (event: MouseEvent, row: T, index: number) => void;
gap?: 'md' | 'lg';
stagger?: boolean;
class?: string;
}
@@ -27,6 +31,7 @@
rowClass,
oncontextmenu,
gap = 'md',
stagger = false,
class: klass = ''
}: Props = $props();
@@ -60,10 +65,12 @@
{#each rows as row, i (rowKey(row))}
<div
role="listitem"
animate:flip={{ duration: 200, easing: expoOut }}
out:collapse
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:pr-6 sm:pl-3 {GAP[
gap
]} {rowClass?.(row, i) ?? ''}"
{style}
]} {stagger ? 'animate-enter' : ''} {rowClass?.(row, i) ?? ''}"
style="{style}{stagger ? `; --i:${i}` : ''}"
oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, row, i) : undefined}
>
{#each columns as column (column.id)}
@@ -2,10 +2,11 @@
interface Props {
label: string;
tone?: 'success' | 'neutral' | 'danger';
pulse?: boolean;
class?: string;
}
let { label, tone = 'neutral', class: klass = '' }: Props = $props();
let { label, tone = 'neutral', pulse = false, class: klass = '' }: Props = $props();
const TONE = {
success: 'bg-accent-tint text-accent-soft',
@@ -15,7 +16,7 @@
</script>
<span
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap {TONE[
tone
]} {klass}">{label}</span
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap {pulse
? 'animate-pulse'
: ''} {TONE[tone]} {klass}">{label}</span
>
@@ -12,16 +12,43 @@
}
let { label, tabs, class: klass = '' }: Props = $props();
let links = $state<Record<string, HTMLAnchorElement | undefined>>({});
let indicator = $state({ x: 0, width: 0 });
let measured = $state(false);
let animated = $state(false);
const activeHref = $derived(tabs.find((tab) => tab.active)?.href);
$effect(() => {
const link = activeHref ? links[activeHref] : undefined;
if (!link) return;
indicator = { x: link.offsetLeft, width: link.offsetWidth };
measured = true;
if (!animated) requestAnimationFrame(() => (animated = true));
});
</script>
<nav aria-label={label} class="flex gap-2 {klass}">
<nav
aria-label={label}
class="relative inline-flex max-w-full rounded-full bg-surface-2 p-1 {klass}"
>
{#if measured}
<div
class="absolute inset-y-1 left-0 rounded-full bg-fg ease-snappy {animated
? 'transition-[transform,width] duration-200'
: ''}"
style="width:{indicator.width}px; transform:translateX({indicator.x}px)"
></div>
{/if}
{#each tabs as tab (tab.href)}
<a
bind:this={links[tab.href]}
href={tab.href}
aria-current={tab.active ? 'page' : undefined}
class="rounded-full px-4 py-1.5 text-xs font-medium focus-ring transition-colors {tab.active
? 'bg-fg text-ink'
: 'bg-surface-2 text-fg-2 hover:text-fg'}"
class="relative z-(--z-raised) rounded-full px-4 py-1.5 text-xs font-medium focus-ring transition-colors {tab.active
? 'text-ink'
: 'text-fg-2 hover:text-fg'}"
>
{tab.label}
</a>
+21 -1
View File
@@ -1,5 +1,5 @@
import { expoOut } from 'svelte/easing';
import type { TransitionConfig } from 'svelte/transition';
import { crossfade, type TransitionConfig } from 'svelte/transition';
export function pop(
node: Element,
@@ -12,3 +12,23 @@ export function pop(
`opacity: ${t}; transform: scale(${0.98 + 0.02 * t}) translateY(${0.25 * (1 - t)}rem);`
};
}
export function collapse(
node: Element,
{ duration = 200 }: { duration?: number } = {}
): TransitionConfig {
const height = node.getBoundingClientRect().height;
const style = getComputedStyle(node);
const paddingTop = parseFloat(style.paddingTop);
const paddingBottom = parseFloat(style.paddingBottom);
return {
duration,
easing: expoOut,
css: (t) =>
`overflow: hidden; opacity: ${t}; height: ${height * t}px; padding-top: ${paddingTop * t}px; padding-bottom: ${paddingBottom * t}px;`
};
}
export function transferBetweenLists() {
return crossfade({ duration: 200, easing: expoOut });
}
+7 -1
View File
@@ -27,6 +27,12 @@
let scroller = $state<HTMLElement>();
const transitionKey = $derived(
page.url.pathname === '/library' || page.url.pathname.startsWith('/library/')
? '/library'
: page.url.pathname
);
trackNavigation();
restoreScroll(() => scroller);
@@ -81,7 +87,7 @@
<MobileHeader user={data.user} accountUrl={data.accountUrl} />
<TopBar user={data.user} accountUrl={data.accountUrl} />
<main class="flex-1">
{#key page.url.pathname}
{#key transitionKey}
<div class="animate-fade">{@render children()}</div>
{/key}
</main>
@@ -47,5 +47,7 @@
<Page>
<PageHeader title="Gestionar música" description={current.description} />
<Tabs label="Secciones de la biblioteca" {tabs} />
{@render children()}
{#key page.url.pathname}
<div class="animate-fade">{@render children()}</div>
{/key}
</Page>
@@ -6,7 +6,7 @@
let { data, form } = $props();
</script>
<div class="mt-4">
<div class="animate-enter mt-4">
{#key data.album.id}
<LibraryAlbumEditor
album={data.album}
@@ -6,7 +6,7 @@
let { data, form } = $props();
</script>
<div class="mt-4">
<div class="animate-enter mt-4">
<LibraryAlbumEditor
album={null}
artist={page.data.user?.name ?? ''}