<script module lang="ts">
import { tv, type VariantProps } from "tailwind-variants";
export const welcomeCard = tv({
slots: {
root: "overflow-hidden rounded-md border border-border-default bg-background-default",
header: "border-b border-border-default px-xl py-lg",
body: "grid overflow-hidden",
// self-start keeps the pane at its content height: once the body carries a
// measured height the grid row would otherwise stretch the pane to match it,
// and measuring that back is a loop that never moves
pane: "col-start-1 row-start-1 min-w-0 self-start p-xl focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus",
footer: "flex items-center justify-between gap-md border-t border-border-default px-xl py-md"
}
});
export type WelcomeCardVariants = VariantProps<typeof welcomeCard>;
</script>
<script lang="ts">
import type { Snippet } from "svelte";
import { untrack } from "svelte";
import { cubicIn } from "svelte/easing";
import { prefersReducedMotion } from "svelte/motion";
import { fade, fly } from "svelte/transition";
import ArrowLeft from "$icon/arrow-left";
import ArrowRight from "$icon/arrow-right";
import Check from "$icon/check";
import Button from "#lib/components/ui/Button.svelte";
import {
CROSSFADE_DURATION,
CROSSFADE_EASING,
crossfadeEasing,
EXIT_DURATION
} from "#lib/motion/spring.ts";
import Steps from "#lib/components/welcome/Steps.svelte";
interface Props {
steps: readonly { key: string; label: string }[];
step: number;
furthest: number;
direction: number;
nextReady?: boolean;
nextLoading?: boolean;
onBack: () => void;
onNext: () => void;
onSelect: (index: number) => void;
start?: Snippet;
children: Snippet<[number]>;
}
let {
steps,
step,
furthest,
direction,
nextReady = false,
nextLoading = false,
onBack,
onNext,
onSelect,
start,
children
}: Props = $props();
const slot = welcomeCard();
const isLast = $derived(step === steps.length - 1);
const DISTANCE = 22;
const enter = $derived(
prefersReducedMotion.current
? { duration: CROSSFADE_DURATION, easing: crossfadeEasing }
: { x: direction * DISTANCE, duration: CROSSFADE_DURATION, easing: crossfadeEasing }
);
const leave = $derived(
prefersReducedMotion.current
? { duration: EXIT_DURATION, easing: cubicIn }
: { x: direction * -DISTANCE, duration: EXIT_DURATION, easing: cubicIn }
);
const stepDescription = (index: number) =>
`Step ${index + 1} of ${steps.length}: ${steps[index].label}`;
const mobileLabelTransition = $derived(
prefersReducedMotion.current
? ""
: `transition: opacity ${CROSSFADE_DURATION}ms ${CROSSFADE_EASING};`
);
let height = $state<number | null>(null);
let mounted = $state(false);
$effect(() => {
if (height !== null && !mounted) mounted = true;
});
const heightTransition = $derived(
mounted && !prefersReducedMotion.current
? `transition: height ${CROSSFADE_DURATION}ms ${CROSSFADE_EASING};`
: ""
);
function measurePane(node: HTMLElement) {
// a detached pane reports 0, and the outgoing one is still observed while it
// fades out — taking that would collapse the card mid-transition
const observer = new ResizeObserver(() => {
if (node.isConnected) height = node.offsetHeight;
});
observer.observe(node);
height = node.offsetHeight;
return {
destroy() {
observer.disconnect();
}
};
}
let paneEl: HTMLElement | undefined = $state();
let intent: "panel" | null = null;
$effect(() => {
void step;
if (intent === "panel") paneEl?.focus();
intent = null;
});
function handleBack() {
intent = "panel";
onBack();
}
function handleNext() {
intent = "panel";
onNext();
}
const labelFadeMs = $derived(prefersReducedMotion.current ? 0 : 160);
const backFadeInMs = $derived(prefersReducedMotion.current ? 0 : 160);
const backFadeOutMs = $derived(prefersReducedMotion.current ? 0 : 120);
</script>
<div class={slot.root()}>
<p aria-live="polite" class="sr-only">{stepDescription(step)}</p>
<div class={slot.header()}>
<div class="relative grid sm:hidden" aria-hidden="true">
{#each steps as item, index (item.key)}
<span
class="col-start-1 row-start-1 typography-paragraph-small whitespace-nowrap text-foreground-default"
style="opacity: {index === step ? 1 : 0}; {mobileLabelTransition}"
>
{item.label}
</span>
{/each}
</div>
<Steps items={steps} current={step} {furthest} complete={isLast} {onSelect} />
</div>
<div
class={slot.body()}
style="{height === null ? '' : `height: ${height}px;`} {heightTransition}"
>
{#key step}
{@const frozen = untrack(() => step)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
bind:this={paneEl}
use:measurePane
class={slot.pane()}
tabindex={-1}
role="group"
aria-label={stepDescription(frozen)}
in:fly={enter}
out:fly={leave}
>
{@render children(frozen)}
</div>
{/key}
</div>
<div class={slot.footer()}>
<div class="flex items-center gap-2">
{#if start && step === 0}
{@render start()}
{/if}
{#if step > 0}
<div in:fade={{ duration: backFadeInMs }} out:fade={{ duration: backFadeOutMs }}>
<Button variant="ghost" icon={ArrowLeft} onclick={handleBack}>Back</Button>
</div>
{/if}
</div>
<Button
variant={nextReady ? "primary" : "default"}
icon={isLast ? Check : ArrowRight}
iconSide="right"
loading={nextLoading}
disabled={nextLoading}
onclick={handleNext}
>
<span class="grid">
<span
class="col-start-1 row-start-1"
style="opacity: {isLast ? 0 : 1}; transition: opacity {labelFadeMs}ms;"
aria-hidden={isLast}
>
Next
</span>
<span
class="col-start-1 row-start-1"
style="opacity: {isLast ? 1 : 0}; transition: opacity {labelFadeMs}ms;"
aria-hidden={!isLast}
>
Finish
</span>
</span>
</Button>
</div>
</div>