web-player: unify primitive link/button rendering, add onEscape action and self-contained AccountMenu

This commit is contained in:
2026-09-24 05:27:25 +02:00
parent 5cda7cb4c1
commit 8cb9386447
12 changed files with 103 additions and 128 deletions
+17
View File
@@ -0,0 +1,17 @@
import type { Action } from 'svelte/action';
export const onEscape: Action<HTMLElement, (() => void) | undefined> = (_node, handler) => {
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') handler?.();
}
window.addEventListener('keydown', onKeydown);
return {
update(next) {
handler = next;
},
destroy() {
window.removeEventListener('keydown', onKeydown);
}
};
};
@@ -5,7 +5,6 @@
import LogIn from '@lucide/svelte/icons/log-in'; import LogIn from '@lucide/svelte/icons/log-in';
import MenuItem from '../ui/overlay/MenuItem.svelte'; import MenuItem from '../ui/overlay/MenuItem.svelte';
import AccountMenu from '../ui/overlay/AccountMenu.svelte'; import AccountMenu from '../ui/overlay/AccountMenu.svelte';
import Avatar from '../ui/media/Avatar.svelte';
import IconButton from '../ui/primitives/IconButton.svelte'; import IconButton from '../ui/primitives/IconButton.svelte';
import Logo from '../ui/primitives/Logo.svelte'; import Logo from '../ui/primitives/Logo.svelte';
@@ -26,17 +25,6 @@
{#if user} {#if user}
<AccountMenu {user} {accountUrl} width="md"> <AccountMenu {user} {accountUrl} width="md">
{#snippet trigger({ toggle, open })}
<button
type="button"
onclick={toggle}
aria-label="Tu cuenta"
aria-expanded={open}
class="flex items-center rounded-full"
>
<Avatar name={user.name} src={user.picture} size="sm" />
</button>
{/snippet}
{#snippet extraItems({ close })} {#snippet extraItems({ close })}
<MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} /> <MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} />
<MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} /> <MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} />
@@ -8,7 +8,6 @@
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import AccountMenu from '../ui/overlay/AccountMenu.svelte'; import AccountMenu from '../ui/overlay/AccountMenu.svelte';
import Avatar from '../ui/media/Avatar.svelte';
import IconButton from '../ui/primitives/IconButton.svelte'; import IconButton from '../ui/primitives/IconButton.svelte';
import Button from '../ui/primitives/Button.svelte'; import Button from '../ui/primitives/Button.svelte';
@@ -86,24 +85,7 @@
<div class="ml-auto shrink-0"> <div class="ml-auto shrink-0">
{#if user} {#if user}
<AccountMenu {user} {accountUrl}> <AccountMenu {user} {accountUrl} />
{#snippet trigger({ toggle, open })}
<button
type="button"
onclick={toggle}
aria-label="Tu cuenta"
aria-expanded={open}
class="block shrink-0 rounded-full transition active:scale-95"
>
<Avatar
name={user.name}
src={user.picture}
size="sm"
class="border border-line-strong opacity-90"
/>
</button>
{/snippet}
</AccountMenu>
{:else} {:else}
<Button href="/auth/login" reload variant="accent" size="sm" class="shrink-0" <Button href="/auth/login" reload variant="accent" size="sm" class="shrink-0"
>Iniciar sesión</Button >Iniciar sesión</Button
@@ -62,19 +62,10 @@
const resolvedImageSize = $derived(imageSize ?? AUTO_IMAGE_SIZE[size]); const resolvedImageSize = $derived(imageSize ?? AUTO_IMAGE_SIZE[size]);
const radius = $derived(shape === 'round' ? 'rounded-full' : RADIUS[size]); const radius = $derived(shape === 'round' ? 'rounded-full' : RADIUS[size]);
let srcFailed = $state(false); let failedSrc = $state<string | null>(null);
$effect(() => { let failedSingle = $state<string | null>(null);
src;
srcFailed = false;
});
let singleFailed = $state(false); const showSrc = $derived(!!src && failedSrc !== src);
$effect(() => {
trackIds[0];
singleFailed = false;
});
const showSrc = $derived(!!src && !srcFailed);
const showMosaic = $derived(!showSrc && trackIds.length >= 4); const showMosaic = $derived(!showSrc && trackIds.length >= 4);
const singleId = $derived(!showSrc && !showMosaic && trackIds.length > 0 ? trackIds[0] : null); const singleId = $derived(!showSrc && !showMosaic && trackIds.length > 0 ? trackIds[0] : null);
</script> </script>
@@ -87,7 +78,7 @@
loading="lazy" loading="lazy"
decoding="async" decoding="async"
fetchpriority="low" fetchpriority="low"
onerror={() => (srcFailed = true)} onerror={() => (failedSrc = src ?? null)}
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else if showMosaic} {:else if showMosaic}
@@ -104,14 +95,14 @@
/> />
{/each} {/each}
</div> </div>
{:else if singleId !== null && !singleFailed} {:else if singleId !== null && failedSingle !== singleId}
<img <img
src={trackCover(singleId, resolvedImageSize)} src={trackCover(singleId, resolvedImageSize)}
{alt} {alt}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
fetchpriority="low" fetchpriority="low"
onerror={() => (singleFailed = true)} onerror={() => (failedSingle = singleId)}
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else} {:else}
@@ -33,11 +33,7 @@
.join('') .join('')
); );
let failed = $state(false); let failedSrc = $state<string | null>(null);
$effect(() => {
src;
failed = false;
});
</script> </script>
<div <div
@@ -45,13 +41,13 @@
size size
]} {klass}" ]} {klass}"
> >
{#if src && !failed} {#if src && failedSrc !== src}
<img <img
{src} {src}
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onerror={() => (failed = true)} onerror={() => (failedSrc = src ?? null)}
class="absolute inset-0 h-full w-full object-cover" class="absolute inset-0 h-full w-full object-cover"
/> />
{:else} {:else}
@@ -10,16 +10,17 @@
import GlassMenu from './GlassMenu.svelte'; import GlassMenu from './GlassMenu.svelte';
import { themeMode } from '$lib/theme/mode.svelte'; import { themeMode } from '$lib/theme/mode.svelte';
import { clickOutside } from '$lib/actions/clickOutside'; import { clickOutside } from '$lib/actions/clickOutside';
import { onEscape } from '$lib/actions/onEscape';
import Avatar from '../media/Avatar.svelte';
interface Props { interface Props {
user: SessionUser; user: SessionUser;
accountUrl?: string | null; accountUrl?: string | null;
width?: 'sm' | 'md'; width?: 'sm' | 'md';
extraItems?: Snippet<[{ close: () => void }]>; extraItems?: Snippet<[{ close: () => void }]>;
trigger: Snippet<[{ toggle: () => void; open: boolean }]>;
} }
let { user, accountUrl, width = 'sm', extraItems, trigger }: Props = $props(); let { user, accountUrl, width = 'sm', extraItems }: Props = $props();
const WIDTH: Record<'sm' | 'md', string> = { sm: 'w-52', md: 'w-56' }; const WIDTH: Record<'sm' | 'md', string> = { sm: 'w-52', md: 'w-56' };
@@ -32,16 +33,23 @@
function close() { function close() {
open = false; open = false;
} }
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') close();
}
</script> </script>
<svelte:window onkeydown={onWindowKeydown} /> <div class="relative" use:clickOutside={close} use:onEscape={open ? close : undefined}>
<button
<div class="relative" use:clickOutside={close}> type="button"
{@render trigger({ toggle, open })} onclick={toggle}
aria-label="Tu cuenta"
aria-expanded={open}
class="block shrink-0 rounded-full transition active:scale-95"
>
<Avatar
name={user.name}
src={user.picture}
size="sm"
class="border border-line-strong opacity-90"
/>
</button>
{#if open} {#if open}
<GlassMenu <GlassMenu
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { onEscape } from '$lib/actions/onEscape';
import { playlistCover } from '$lib/utils/hrefs'; import { playlistCover } from '$lib/utils/hrefs';
import type { LucideIcon } from '@lucide/svelte'; import type { LucideIcon } from '@lucide/svelte';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
@@ -18,17 +19,12 @@
} }
let { x, y, openLeft, onClose, items, playlistAction, playlists = [] }: Props = $props(); let { x, y, openLeft, onClose, items, playlistAction, playlists = [] }: Props = $props();
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script> </script>
<svelte:window onkeydown={onWindowKeydown} />
<div <div
class="fixed inset-0 z-(--z-backdrop)" class="fixed inset-0 z-(--z-backdrop)"
role="presentation" role="presentation"
use:onEscape={onClose}
onclick={onClose} onclick={onClose}
oncontextmenu={(e) => { oncontextmenu={(e) => {
e.preventDefault(); e.preventDefault();
@@ -28,21 +28,17 @@
); );
</script> </script>
{#if href} <!-- svelte-ignore a11y_no_static_element_interactions -->
<a <svelte:element
{href} this={href ? 'a' : 'button'}
{target} {href}
rel={target === '_blank' ? 'noopener noreferrer' : undefined} {target}
data-sveltekit-reload={reload ? '' : undefined} rel={href && target === '_blank' ? 'noopener noreferrer' : undefined}
{onclick} type={href ? undefined : type}
class={classes} data-sveltekit-reload={href && reload ? '' : undefined}
> {onclick}
<Icon class="size-icon-md shrink-0" /> class={classes}
{label} >
</a> <Icon class="size-icon-md shrink-0" />
{:else} {label}
<button {type} {onclick} class={classes}> </svelte:element>
<Icon class="size-icon-md shrink-0" />
{label}
</button>
{/if}
@@ -4,6 +4,7 @@
import IconButton from '../primitives/IconButton.svelte'; import IconButton from '../primitives/IconButton.svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { pop } from '$lib/utils/transitions'; import { pop } from '$lib/utils/transitions';
import { onEscape } from '$lib/actions/onEscape';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -48,12 +49,7 @@
} }
function onWindowKeydown(event: KeyboardEvent) { function onWindowKeydown(event: KeyboardEvent) {
if (!open) return; if (!open || event.key !== 'Tab') return;
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key !== 'Tab') return;
const elements = focusableElements(); const elements = focusableElements();
if (elements.length === 0) return; if (elements.length === 0) return;
const first = elements[0]; const first = elements[0];
@@ -79,6 +75,7 @@
> >
<div <div
bind:this={panel} bind:this={panel}
use:onEscape={onClose}
transition:pop={{ duration: 200 }} transition:pop={{ duration: 200 }}
class="glass-panel max-h-[85dvh] w-full glow-drift overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}" class="glass-panel max-h-[85dvh] w-full glow-drift overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
role="dialog" role="dialog"
@@ -46,15 +46,18 @@
); );
</script> </script>
{#if href} <!-- svelte-ignore a11y_no_static_element_interactions -->
<a {href} data-sveltekit-reload={reload ? '' : undefined} class={classes}> <svelte:element
{@render children()} this={href ? 'a' : 'button'}
</a> {href}
{:else} type={href ? undefined : type}
<button {type} disabled={disabled || loading} {onclick} class={classes}> disabled={href ? undefined : disabled || loading}
{#if loading} data-sveltekit-reload={href && reload ? '' : undefined}
<LoaderCircle class="size-4 animate-spin" /> {onclick}
{/if} class={classes}
{@render children()} >
</button> {#if loading && !href}
{/if} <LoaderCircle class="size-4 animate-spin" />
{/if}
{@render children()}
</svelte:element>
@@ -21,15 +21,17 @@
); );
</script> </script>
{#snippet content()} <!-- svelte-ignore a11y_no_static_element_interactions -->
<svelte:element
this={href ? 'a' : 'button'}
{href}
type={href ? undefined : 'button'}
disabled={href ? undefined : disabled}
onclick={href ? undefined : onclick}
class={classes}
>
{@render children()} {@render children()}
{#if count !== undefined} {#if count !== undefined}
<span class="tabular-nums {selected ? 'opacity-55' : 'text-fg-2'}">{count}</span> <span class="tabular-nums {selected ? 'opacity-55' : 'text-fg-2'}">{count}</span>
{/if} {/if}
{/snippet} </svelte:element>
{#if href}
<a {href} class={classes}>{@render content()}</a>
{:else}
<button type="button" {onclick} {disabled} class={classes}>{@render content()}</button>
{/if}
@@ -51,18 +51,17 @@
); );
</script> </script>
{#if href} <!-- svelte-ignore a11y_no_static_element_interactions -->
<a <svelte:element
{href} this={href ? 'a' : 'button'}
data-sveltekit-reload={reload ? '' : undefined} {href}
aria-label={label} type={href ? undefined : type}
title={label} data-sveltekit-reload={href && reload ? '' : undefined}
class={classes} aria-label={label}
> aria-pressed={href ? undefined : pressed}
{@render children()} title={label}
</a> {onclick}
{:else} class={classes}
<button {type} {onclick} aria-label={label} title={label} aria-pressed={pressed} class={classes}> >
{@render children()} {@render children()}
</button> </svelte:element>
{/if}