Christmas Joke Generator
What's Santa's favourite type of music?
Tell me!
Wrap!
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);
}
};
}