Monorepo for Tangled
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221<script module lang="ts"> import { tv, type VariantProps } from "tailwind-variants";
export const welcomeCard = tv({ slots: { root: "overflow-hidden rounded-sm 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"; 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>