fix: Tab transition animations

This commit is contained in:
Aleksi Lassila
2024-06-16 18:39:55 +03:00
parent c62bc83a1a
commit 9b5be9e2ae
9 changed files with 272 additions and 231 deletions

View File

@@ -3,6 +3,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { modalStack } from '../Modal/modal.store'; import { modalStack } from '../Modal/modal.store';
import Panel from '../Panel.svelte';
export let size: 'sm' | 'full' | 'lg' | 'dynamic' = 'sm'; export let size: 'sm' | 'full' | 'lg' | 'dynamic' = 'sm';
@@ -16,20 +17,12 @@
class="h-full flex items-center justify-center bg-primary-900/75 py-20 px-32" class="h-full flex items-center justify-center bg-primary-900/75 py-20 px-32"
transition:fade={{ duration: 100 }} transition:fade={{ duration: 100 }}
on:click|self={() => handleClose()} on:click|self={() => handleClose()}
on:keypress={() => {
/* For a11y*/
}}
> >
<div <Panel {size} class={$$restProps.class}>
class={classNames(
'bg-primary-800 rounded-2xl p-12 relative shadow-xl flex flex-col transition-[max-width]',
{
'flex-1 max-w-lg min-h-0 overflow-y-auto scrollbar-hide': size === 'sm',
'flex-1 h-full overflow-hidden': size === 'full',
'flex-1 max-w-[56rem] min-h-0 overflow-y-auto scrollbar-hide': size === 'lg',
'': size === 'dynamic'
},
$$restProps.class
)}
>
<slot close={handleClose} /> <slot close={handleClose} />
</div> </Panel>
</div> </div>
</Modal> </Modal>

View File

@@ -103,8 +103,8 @@
} }
</script> </script>
<Dialog class="grid" size={$tab === Tabs.EditProfile ? 'sm' : 'dynamic'}> <Dialog class="grid" size={'dynamic'}>
<Tab {...tab} tab={Tabs.EditProfile} class="space-y-4"> <Tab {...tab} tab={Tabs.EditProfile} class="space-y-4 max-w-lg">
<h1 class="header2">Edit Profile</h1> <h1 class="header2">Edit Profile</h1>
<TextField bind:value={name}>name</TextField> <TextField bind:value={name}>name</TextField>
<SelectField value={profilePictureTitle} on:clickOrSelect={() => tab.set(Tabs.ProfilePictures)}> <SelectField value={profilePictureTitle} on:clickOrSelect={() => tab.set(Tabs.ProfilePictures)}>
@@ -151,7 +151,7 @@
}} }}
> >
<h1 class="header2 mb-6">Select Profile Picture</h1> <h1 class="header2 mb-6">Select Profile Picture</h1>
<Container direction="grid" gridCols={3} class="grid grid-cols-3 gap-4"> <Container direction="grid" gridCols={3} class="grid grid-cols-3 gap-4 w-max">
<ProfileIcon <ProfileIcon
url={profilePictures.ana} url={profilePictures.ana}
on:clickOrSelect={() => setProfilePicture(profilePictures.ana)} on:clickOrSelect={() => setProfilePicture(profilePictures.ana)}

View File

@@ -15,9 +15,15 @@
</script> </script>
<Dialog> <Dialog>
{#each users as user} <h1 class="header1 mb-2">Users</h1>
<SelectItem selected={user.Id === selectedUser?.Id} on:clickOrSelect={() => handleSelect(user)}> <div class="space-y-4">
{user.Name} {#each users as user}
</SelectItem> <SelectItem
{/each} selected={user.Id === selectedUser?.Id}
on:clickOrSelect={() => handleSelect(user)}
>
{user.Name}
</SelectItem>
{/each}
</div>
</Dialog> </Dialog>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import classNames from 'classnames';
export let size: 'sm' | 'full' | 'lg' | 'dynamic' = 'sm';
</script>
<div
class={classNames(
'bg-primary-800 rounded-2xl p-12 relative shadow-xl flex flex-col transition-[max-width]',
{
'flex-1 max-w-lg min-h-0 overflow-y-auto scrollbar-hide': size === 'sm',
'flex-1 h-full overflow-hidden': size === 'full',
'flex-1 max-w-[56rem] min-h-0 overflow-y-auto scrollbar-hide': size === 'lg',
'max-w-max overflow-hidden': size === 'dynamic'
},
$$restProps.class
)}
>
<slot />
</div>

View File

@@ -7,7 +7,7 @@
</script> </script>
<Container <Container
class="flex items-center justify-between bg-primary-900 rounded-xl px-6 py-2.5 mb-4 font-medium class="flex items-center justify-between bg-primary-900 rounded-xl px-6 py-2.5 font-medium
border-2 border-transparent focus:border-primary-500 hover:border-primary-500 cursor-pointer group" border-2 border-transparent focus:border-primary-500 hover:border-primary-500 cursor-pointer group"
on:clickOrSelect on:clickOrSelect
on:enter on:enter

View File

@@ -7,6 +7,7 @@
export let tab: number; export let tab: number;
export let index: number = tab; export let index: number = tab;
export let openTab: Writable<number>; export let openTab: Writable<number>;
export let size: 'hug' | 'stretch' = 'hug';
let selectable: Selectable; let selectable: Selectable;
@@ -33,19 +34,24 @@
</script> </script>
<Container <Container
class={classNames( class={classNames('col-start-1 col-end-1 row-start-1 row-end-1', {
$$restProps.class, 'absolute pointer-events-none left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2':
'transition-all col-start-1 col-end-1 row-start-1 row-end-1', !active && size === 'hug',
{ 'absolute pointer-events-none inset-0': !active && size === 'stretch',
'opacity-0 pointer-events-none absolute inset-0': !active, '': active
'-translate-x-10': !active && $openTab >= index, })}
'translate-x-10': !active && $openTab < index
}
)}
bind:selectable bind:selectable
on:back on:back
on:navigate={handleNavigate} on:navigate={handleNavigate}
disabled={!active} disabled={!active}
> >
<slot /> <div
class={classNames($$restProps.class, 'transition-[transform,opacity]', {
'opacity-0 pointer-events-none': !active,
'-translate-x-10': !active && $openTab >= index,
'translate-x-10': !active && $openTab < index
})}
>
<slot />
</div>
</Container> </Container>

View File

@@ -1,10 +1,12 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import type { ComponentProps } from 'svelte';
import Tab from './Tab.svelte';
export function useTabs(defaultTab: number) { export function useTabs(defaultTab: number, props: Pick<ComponentProps<Tab>, 'size'> = {}) {
const openTab = writable<number>(defaultTab); const openTab = writable<number>(defaultTab);
const next = () => openTab.update((n) => n + 1); const next = () => openTab.update((n) => n + 1);
const previous = () => openTab.update((n) => n - 1); const previous = () => openTab.update((n) => n - 1);
return { subscribe: openTab.subscribe, openTab, set: openTab.set, next, previous }; return { subscribe: openTab.subscribe, openTab, set: openTab.set, next, previous, ...props };
} }

View File

@@ -29,7 +29,7 @@
About About
} }
const tab = useTabs(Tabs.Account); const tab = useTabs(Tabs.Interface, { size: 'stretch' });
let jellyfinBaseUrl = ''; let jellyfinBaseUrl = '';
let jellyfinApiKey = ''; let jellyfinApiKey = '';
@@ -133,7 +133,7 @@
}} }}
/> />
<DetachedPage class="px-32 py-16"> <DetachedPage class="px-32 py-16 h-screen flex flex-col">
<Container <Container
direction="horizontal" direction="horizontal"
class="flex space-x-8 header3 pb-3 border-b-2 border-secondary-700 w-full mb-8" class="flex space-x-8 header3 pb-3 border-b-2 border-secondary-700 w-full mb-8"
@@ -185,8 +185,8 @@
</Container> </Container>
</Container> </Container>
<Container class="grid"> <Container class="flex-1 grid w-full overflow-y-auto scrollbar-hide relative">
<Tab {...tab} tab={Tabs.Interface} class=""> <Tab {...tab} tab={Tabs.Interface} class="w-full">
<div class="flex items-center justify-between text-lg font-medium text-secondary-100 py-2"> <div class="flex items-center justify-between text-lg font-medium text-secondary-100 py-2">
<label class="mr-2">Animate scrolling</label> <label class="mr-2">Animate scrolling</label>
<Toggle <Toggle

View File

@@ -14,6 +14,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { user } from '../stores/user.store'; import { user } from '../stores/user.store';
import { sessions } from '../stores/session.store'; import { sessions } from '../stores/session.store';
import Panel from '../components/Panel.svelte';
enum Tabs { enum Tabs {
Welcome, Welcome,
@@ -27,7 +28,7 @@
TmdbConnect = Tmdb + 0.1 TmdbConnect = Tmdb + 0.1
} }
const tab = useTabs(Tabs.Welcome); const tab = useTabs(Tabs.Welcome, { ['class']: 'w-max max-w-lg' });
let tmdbConnectRequestToken: string | undefined = undefined; let tmdbConnectRequestToken: string | undefined = undefined;
let tmdbConnectLink: string | undefined = undefined; let tmdbConnectLink: string | undefined = undefined;
@@ -229,217 +230,230 @@
'col-start-1 col-end-1 row-start-1 row-end-1 flex flex-col bg-primary-800 rounded-2xl p-10 shadow-xl max-w-lg'; 'col-start-1 col-end-1 row-start-1 row-end-1 flex flex-col bg-primary-800 rounded-2xl p-10 shadow-xl max-w-lg';
</script> </script>
<Container focusOnMount class="h-full w-full grid justify-items-center items-center"> <Container focusOnMount class="h-full w-full flex items-center justify-center" on:back={handleBack}>
<div class="flex flex-col bg-primary-800 rounded-2xl p-10 shadow-xl max-w-lg"> <Panel class="grid max-w-lg" size="dynamic">
<div class="relative"> <Tab {...tab} tab={Tabs.Welcome} on:back={({ detail }) => detail.stopPropagation()}>
<Tab {...tab} tab={Tabs.Welcome}> <h1 class="header2 mb-2 w-full">Welcome to Reiverr</h1>
<h1 class="header2 mb-2">Welcome to Reiverr</h1> <div class="body mb-8">
<div class="body mb-8"> Looks like this is a new account. This setup will get you started with connecting your
Looks like this is a new account. This setup will get you started with connecting your services to get most out of Reiverr.
services to get most out of Reiverr. </div>
</div> <Container direction="horizontal" class="flex space-x-4">
<Container direction="horizontal" class="flex space-x-4"> <Button type="primary-dark" on:clickOrSelect={() => sessions.removeSession()}
<Button type="primary-dark" on:clickOrSelect={() => sessions.removeSession()} >Log Out</Button
>Log Out</Button >
> <div class="flex-1">
<div class="flex-1">
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
Next
<div class="absolute inset-y-0 right-0 flex items-center justify-center">
<ArrowRight size={24} />
</div>
</Button>
</div>
</Container>
</Tab>
<Tab {...tab} tab={Tabs.Tmdb} on:back={handleBack}>
<h1 class="header2 mb-2">Connect a TMDB Account</h1>
<div class="body mb-8">
Connect to TMDB for personalized recommendations based on your movie reviews and
preferences.
</div>
<div class="space-y-4 flex flex-col">
{#await connectedTmdbAccount then account}
{#if account}
<SelectField
value={account.username || ''}
on:clickOrSelect={() => {
tab.set(Tabs.TmdbConnect);
handleGenerateTMDBLink();
}}>Logged in as</SelectField
>
{:else}
<Button
type="primary-dark"
on:clickOrSelect={() => {
tab.set(Tabs.TmdbConnect);
handleGenerateTMDBLink();
}}
>
Connect
<ArrowRight size={19} slot="icon-absolute" />
</Button>
{/if}
{/await}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}> <Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
{#if $user?.settings.tmdb.userId} Next
Next <div class="absolute inset-y-0 right-0 flex items-center justify-center">
{:else} <ArrowRight size={24} />
Skip </div>
{/if}
<ArrowRight size={19} slot="icon-absolute" />
</Button> </Button>
</div> </div>
</Tab> </Container>
</Tab>
<Tab {...tab} tab={Tabs.TmdbConnect} on:back={() => tab.set(Tabs.Tmdb)}> <Tab {...tab} tab={Tabs.Tmdb}>
<h1 class="header2 mb-2">Connect a TMDB Account</h1> <h1 class="header2 mb-2">Connect a TMDB Account</h1>
<div class="body mb-8"> <div class="body mb-8">
To connect your TMDB account, log in via the link below and then click "Complete Connect to TMDB for personalized recommendations based on your movie reviews and
Connection". preferences.
</div> </div>
{#if tmdbConnectQrCode} <div class="space-y-4 flex flex-col">
<div {#await connectedTmdbAccount then account}
class="w-[150px] h-[150px] bg-contain bg-center mb-8 mx-auto" {#if account}
style={`background-image: url(${tmdbConnectQrCode})`}
/>
{/if}
<Container direction="horizontal" class="flex space-x-4 *:flex-1">
{#if !tmdbConnectRequestToken}
<Button type="primary-dark" action={handleGenerateTMDBLink}>Generate Link</Button>
{:else if tmdbConnectLink}
<Button type="primary-dark" action={completeTMDBConnect}>Complete Connection</Button>
<Button type="primary-dark" on:clickOrSelect={() => window.open(tmdbConnectLink)}>
Open Link
<ExternalLink size={19} slot="icon-after" />
</Button>
{/if}
</Container>
</Tab>
<Tab {...tab} tab={Tabs.Jellyfin}>
<h1 class="header2 mb-2">Connect to Jellyfin</h1>
<div class="mb-8 body">Connect to Jellyfin to watch movies and tv shows.</div>
<div class="space-y-4 mb-4">
<TextField bind:value={jellyfinBaseUrl} isValid={jellyfinUsers.then((u) => !!u?.length)}>
Base Url
</TextField>
<TextField bind:value={jellyfinApiKey} isValid={jellyfinUsers.then((u) => !!u?.length)}>
API Key
</TextField>
</div>
{#await jellyfinUsers then users}
{#if users.length}
<SelectField <SelectField
value={jellyfinUser?.Name || 'Select User'} value={account.username || ''}
on:clickOrSelect={() => tab.set(Tabs.SelectUser)}
>
User
</SelectField>
{/if}
{/await}
{#if jellyfinError}
<div class="text-red-500 mb-4">{jellyfinError}</div>
{/if}
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
{#if jellyfinBaseUrl && jellyfinApiKey && jellyfinUser}
<Button type="primary-dark" action={handleConnectJellyfin}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if}
</Container>
</Tab>
<Tab {...tab} tab={Tabs.SelectUser} on:back={() => tab.set(Tabs.Jellyfin)}>
<h1 class="header1 mb-2">Select User</h1>
{#await jellyfinUsers then users}
{#each users as user}
<SelectItem
selected={user?.Id === jellyfinUser?.Id}
on:clickOrSelect={() => { on:clickOrSelect={() => {
jellyfinUser = user; tab.set(Tabs.TmdbConnect);
tab.set(Tabs.Jellyfin); handleGenerateTMDBLink();
}}>Logged in as</SelectField
>
{:else}
<Button
type="primary-dark"
on:clickOrSelect={() => {
tab.set(Tabs.TmdbConnect);
handleGenerateTMDBLink();
}} }}
> >
{user.Name} Connect
</SelectItem> <ArrowRight size={19} slot="icon-absolute" />
{/each} </Button>
{/if}
{/await} {/await}
</Tab>
<Tab {...tab} tab={Tabs.Sonarr}> <Button type="primary-dark" on:clickOrSelect={() => tab.next()}>
<h1 class="header2 mb-2">Connect to Sonarr</h1> {#if $user?.settings.tmdb.userId}
<div class="mb-8">Connect to Sonarr for requesting and managing tv shows.</div> Next
<div class="space-y-4 mb-4">
<TextField bind:value={sonarrBaseUrl}>Base Url</TextField>
<TextField bind:value={sonarrApiKey}>API Key</TextField>
</div>
{#if sonarrError}
<div class="text-red-500 mb-4">{sonarrError}</div>
{/if}
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
{#if sonarrBaseUrl && sonarrApiKey}
<Button type="primary-dark" action={handleConnectSonarr}>Connect</Button>
{:else} {:else}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button> Skip
{/if} {/if}
</Container> <ArrowRight size={19} slot="icon-absolute" />
</Tab> </Button>
</div>
</Tab>
<Tab {...tab} tab={Tabs.Radarr}> <Tab
<h1 class="header2 mb-2">Connect to Radarr</h1> {...tab}
<div class="mb-8">Connect to Radarr for requesting and managing movies.</div> tab={Tabs.TmdbConnect}
on:back={({ detail }) => {
tab.set(Tabs.Tmdb);
detail.stopPropagation();
}}
>
<h1 class="header2 mb-2">Connect a TMDB Account</h1>
<div class="body mb-8">
To connect your TMDB account, log in via the link below and then click "Complete
Connection".
</div>
<div class="space-y-4 mb-4"> {#if tmdbConnectQrCode}
<TextField bind:value={radarrBaseUrl}>Base Url</TextField> <div
<TextField bind:value={radarrApiKey}>API Key</TextField> class="w-[150px] h-[150px] bg-contain bg-center mb-8 mx-auto"
</div> style={`background-image: url(${tmdbConnectQrCode})`}
/>
{/if}
{#if radarrError} <Container direction="horizontal" class="flex space-x-4 *:flex-1">
<div class="text-red-500 mb-4">{radarrError}</div> {#if !tmdbConnectRequestToken}
<Button type="primary-dark" action={handleGenerateTMDBLink}>Generate Link</Button>
{:else if tmdbConnectLink}
<Button type="primary-dark" action={completeTMDBConnect}>Complete Connection</Button>
<Button type="primary-dark" on:clickOrSelect={() => window.open(tmdbConnectLink)}>
Open Link
<ExternalLink size={19} slot="icon-after" />
</Button>
{/if} {/if}
</Container>
</Tab>
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4"> <Tab {...tab} tab={Tabs.Jellyfin}>
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button> <h1 class="header2 mb-2">Connect to Jellyfin</h1>
{#if radarrBaseUrl && radarrApiKey} <div class="mb-8 body">Connect to Jellyfin to watch movies and tv shows.</div>
<Button type="primary-dark" action={handleConnectRadarr}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if}
</Container>
</Tab>
<Tab {...tab} tab={Tabs.Complete} class={classNames('w-full')}> <div class="space-y-4 mb-4">
<div class="flex items-center justify-center text-secondary-500 mb-4"> <TextField bind:value={jellyfinBaseUrl} isValid={jellyfinUsers.then((u) => !!u?.length)}>
<CheckCircled size={64} /> Base Url
</div> </TextField>
<h1 class="header2 text-center w-full">All Set!</h1> <TextField bind:value={jellyfinApiKey} isValid={jellyfinUsers.then((u) => !!u?.length)}>
<div class="header1 mb-8 text-center">Reiverr is now ready to use.</div> API Key
</TextField>
</div>
<Container direction="horizontal" class="inline-flex space-x-4 w-full"> {#await jellyfinUsers then users}
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()} icon={ArrowLeft} {#if users.length}
>Back</Button <SelectField
value={jellyfinUser?.Name || 'Select User'}
on:clickOrSelect={() => tab.set(Tabs.SelectUser)}
> >
<div class="flex-1"> User
<Button type="primary-dark" on:clickOrSelect={finalizeSetup} iconAbsolute={ArrowRight} </SelectField>
>Done</Button {/if}
> {/await}
</div>
</Container> {#if jellyfinError}
</Tab> <div class="text-red-500 mb-4">{jellyfinError}</div>
</div> {/if}
</div>
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
{#if jellyfinBaseUrl && jellyfinApiKey && jellyfinUser}
<Button type="primary-dark" action={handleConnectJellyfin}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if}
</Container>
</Tab>
<Tab
{...tab}
tab={Tabs.SelectUser}
on:back={({ detail }) => {
tab.set(Tabs.Jellyfin);
detail.stopPropagation();
}}
>
<h1 class="header1 mb-2 w-96">Select User</h1>
<div class="flex flex-col space-y-4" />
{#await jellyfinUsers then users}
{#each users as user}
<SelectItem
selected={user?.Id === jellyfinUser?.Id}
on:clickOrSelect={() => {
jellyfinUser = user;
tab.set(Tabs.Jellyfin);
}}
>
{user.Name}
</SelectItem>
{/each}
{/await}
</Tab>
<Tab {...tab} tab={Tabs.Sonarr}>
<h1 class="header2 mb-2">Connect to Sonarr</h1>
<div class="mb-8">Connect to Sonarr for requesting and managing tv shows.</div>
<div class="space-y-4 mb-4">
<TextField bind:value={sonarrBaseUrl}>Base Url</TextField>
<TextField bind:value={sonarrApiKey}>API Key</TextField>
</div>
{#if sonarrError}
<div class="text-red-500 mb-4">{sonarrError}</div>
{/if}
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
{#if sonarrBaseUrl && sonarrApiKey}
<Button type="primary-dark" action={handleConnectSonarr}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if}
</Container>
</Tab>
<Tab {...tab} tab={Tabs.Radarr}>
<h1 class="header2 mb-2">Connect to Radarr</h1>
<div class="mb-8">Connect to Radarr for requesting and managing movies.</div>
<div class="space-y-4 mb-4">
<TextField bind:value={radarrBaseUrl}>Base Url</TextField>
<TextField bind:value={radarrApiKey}>API Key</TextField>
</div>
{#if radarrError}
<div class="text-red-500 mb-4">{radarrError}</div>
{/if}
<Container direction="horizontal" class="grid grid-cols-2 gap-4 mt-4">
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()}>Back</Button>
{#if radarrBaseUrl && radarrApiKey}
<Button type="primary-dark" action={handleConnectRadarr}>Connect</Button>
{:else}
<Button type="primary-dark" on:clickOrSelect={() => tab.next()}>Skip</Button>
{/if}
</Container>
</Tab>
<Tab {...tab} tab={Tabs.Complete} class={classNames('w-full')}>
<div class="flex items-center justify-center text-secondary-500 mb-4">
<CheckCircled size={64} />
</div>
<h1 class="header2 text-center w-full">All Set!</h1>
<div class="header1 mb-8 text-center">Reiverr is now ready to use.</div>
<Container direction="horizontal" class="inline-flex space-x-4 w-full">
<Button type="primary-dark" on:clickOrSelect={() => tab.previous()} icon={ArrowLeft}
>Back</Button
>
<div class="flex-1">
<Button type="primary-dark" on:clickOrSelect={finalizeSetup} iconAbsolute={ArrowRight}
>Done</Button
>
</div>
</Container>
</Tab>
</Panel>
</Container> </Container>