web-player: unify primitive link/button rendering, add onEscape action and self-contained AccountMenu
This commit is contained in:
@@ -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}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user