Advent of SvelteKit 2022

Christmas Joke Generator

Why couldn't the skeleton go to the Christmas party?

Tell me!

Because he had no body to go with!

Challenge

Source code

+page.svelte

<script lang="ts">
	import { goto } from '$app/navigation';
	import { navigating, page } from '$app/stores';
	import type { PageData } from './$types';
	import Spinner from '$lib/Spinner.svelte';
	import animate from './animate';

	export let data: PageData;

	$: joke = data.joke;

	let open = false;

	async function handleSubmit(e: SubmitEvent) {
		const form = e.target as HTMLFormElement;
		await goto(form.action, { invalidateAll: true, replaceState: true });
		open = false; // close the summary
	}

	$: isLoading = $navigating?.to?.url.pathname === $page.url.pathname;
</script>

<h1>Christmas Joke Generator</h1>
<p class="setup">{joke.setup}</p>
<div class="wrapper">
	<details bind:open use:animate>
		<summary>Tell me!</summary>
		<div class="flex content">
			<p class="delivery">{joke.delivery}</p>
			<form on:submit|preventDefault={handleSubmit}>
				<button disabled={isLoading}
					>Another! 🎅
					{#if isLoading}<Spinner />{/if}</button
				>
			</form>
		</div>
	</details>
</div>

<style>
	p,
	details {
		font-size: var(--size-4);
	}

	button,
	summary {
		display: block;
		text-align: center;
		background-color: white;
		padding: var(--size-2) 0;
		border-radius: var(--radius-2);
		cursor: pointer;
		border: var(--border-size-3) solid var(--svelte);
	}

	*:focus-visible {
		--outline-color: black;
	}

	button {
		position: relative;
		color: black;
		--fill: var(--gray-3);
		--bg: black;
	}

	button :global(svg) {
		position: absolute;
		right: 6px;
		top: 0.5em;
	}

	/* Hide marker in Safari */
	summary::-webkit-details-marker {
		display: none;
	}

	form,
	button,
	details {
		width: 100%;
	}

	.setup,
	.delivery {
		background-color: var(--blue-3);
		padding: var(--size-2);
		border-radius: var(--radius-2);
		width: 75%;
	}

	.content {
		padding-top: 1rem;
		display: flex;
		flex-direction: column;
		gap: 1rem;
	}

	.setup {
		align-self: start;
	}

	.delivery {
		align-self: end;
	}

	.wrapper {
		/* This can't go on the details, otherwise we'll get a glitchy animation */
		padding: var(--size-2);
		background: var(--gray-3);
		border-radius: var(--radius-2);
		width: 100%;
	}
</style>

+page.ts

import type { PageLoad } from './$types';

interface JokeResponse {
	error: boolean;
	setup: string;
	delivery: string;
	id: number;
}

export const load: PageLoad = async ({ fetch }) => {
	const { setup, delivery, id }: JokeResponse = await fetch(
		'https://v2.jokeapi.dev/joke/christmas'
	).then((r) => r.json());
	return {
		joke: {
			setup,
			delivery,
			id
		}
	};
};

animate.ts

// use WAAPI to animate the details opening/closing
// adapted from https://css-tricks.com/how-to-animate-the-details-element-using-waapi/
// can't use Svelte transitions since the elements are already in the DOM
export default function animate(
	el: HTMLDetailsElement,
	options: KeyframeAnimationOptions = { duration: 250, easing: 'ease-out' }
) {
	el.addEventListener('click', handleClick);
	const summary = el.querySelector('summary');
	const content = el.querySelector('.content') as HTMLDivElement;
	let animation: Animation;

	function handleClick(e: Event) {
		// only trigger on details/summary clicks (not form submit)
		if (e.target !== el && e.target !== summary) return;
		e.preventDefault();
		el.style.overflow = 'hidden';

		if (el.open) {
			shrink();
		} else {
			el.style.height = `${el.offsetHeight}px`;
			el.open = true;
			window.requestAnimationFrame(expand);
		}
	}

	function shrink() {
		if (!summary || !content) return;
		if (animation) animation.cancel();

		const startHeight = el.offsetHeight;
		const endHeight = summary.offsetHeight;
		animate(startHeight, endHeight, false);
	}

	function expand() {
		if (!summary || !content) return;
		if (animation) animation.cancel();

		const startHeight = el.offsetHeight;
		const endHeight = summary.offsetHeight + content.offsetHeight;
		animate(startHeight, endHeight, true);
	}

	function animate(start: number, end: number, open: boolean) {
		const keyframes = {
			height: [`${start}px`, `${end}px`]
		};
		animation = el.animate(keyframes, options);
		animation.onfinish = () => {
			el.open = open;
			el.style.height = el.style.overflow = '';
		};
	}

	return {
		destroy: () => {
			el.removeEventListener('click', handleClick);
		}
	};
}