/ src / components / Quiz.svelte
Quiz.svelte
  1  <script lang="ts">
  2  import { animate } from "motion";
  3  
  4  interface Option {
  5  	id: string;
  6  	text: string;
  7  	isCorrect: boolean;
  8  }
  9  
 10  interface QuizResponse {
 11  	title: string;
 12  	message: string;
 13  }
 14  
 15  interface Props {
 16  	question: string;
 17  	options: Option[];
 18  	correctResponse?: QuizResponse;
 19  	errorResponse?: QuizResponse;
 20  }
 21  
 22  let { question, options, correctResponse, errorResponse }: Props = $props();
 23  
 24  const defaultCorrectResponse: QuizResponse = {
 25  	title: "Correct!",
 26  	message: "You got it right! Nice job.",
 27  };
 28  
 29  const defaultErrorResponse: QuizResponse = {
 30  	title: "Not quite!",
 31  	message: "That's not the right answer. Give it another try!",
 32  };
 33  
 34  let correctMsg = $derived(correctResponse ?? defaultCorrectResponse);
 35  let errorMsg = $derived(errorResponse ?? defaultErrorResponse);
 36  
 37  let selectedOption = $state<string | null>(null);
 38  let hasAnswered = $state(false);
 39  let showExplanation = $state(false);
 40  
 41  let optionRefs: Record<string, HTMLButtonElement> = $state({});
 42  let cardRef: HTMLDivElement;
 43  
 44  function handleSelect(option: Option) {
 45  	if (hasAnswered) return;
 46  
 47  	selectedOption = option.id;
 48  	hasAnswered = true;
 49  
 50  	const prefersReduced = window.matchMedia(
 51  		"(prefers-reduced-motion: reduce)",
 52  	).matches;
 53  
 54  	if (option.isCorrect) {
 55  		// Satisfying correct animation
 56  		const btn = optionRefs[option.id];
 57  		if (btn && !prefersReduced) {
 58  			// Scale up with bounce
 59  			animate(
 60  				btn,
 61  				{ scale: [1, 1.05, 1] },
 62  				{ duration: 0.4, ease: [0.34, 1.56, 0.64, 1] },
 63  			);
 64  			// Green glow pulse
 65  			animate(
 66  				btn,
 67  				{
 68  					boxShadow: [
 69  						"0 0 0 0 rgba(74, 222, 128, 0)",
 70  						"0 0 0 12px rgba(74, 222, 128, 0.2)",
 71  						"0 0 0 4px rgba(74, 222, 128, 0.3)",
 72  					],
 73  				},
 74  				{ duration: 0.6, ease: "easeOut" },
 75  			);
 76  		}
 77  	} else {
 78  		// Scary error animation
 79  		const btn = optionRefs[option.id];
 80  		if (btn && !prefersReduced) {
 81  			// Violent shake
 82  			animate(
 83  				btn,
 84  				{ x: [0, -8, 8, -8, 8, -4, 4, -2, 2, 0] },
 85  				{ duration: 0.5, ease: "easeInOut" },
 86  			);
 87  			// Red flash
 88  			animate(
 89  				btn,
 90  				{
 91  					backgroundColor: ["#fff", "#fee2e2", "#fff"],
 92  					borderColor: ["#f9a8d4", "#ef4444", "#f9a8d4"],
 93  				},
 94  				{ duration: 0.4 },
 95  			);
 96  		}
 97  
 98  		// Also animate the whole card slightly
 99  		if (cardRef && !prefersReduced) {
100  			animate(
101  				cardRef,
102  				{ x: [0, -4, 4, -4, 4, 0] },
103  				{ duration: 0.4, ease: "easeInOut" },
104  			);
105  		}
106  	}
107  
108  	// Show explanation after a brief delay
109  	setTimeout(() => {
110  		showExplanation = true;
111  	}, 300);
112  }
113  
114  function reset() {
115  	selectedOption = null;
116  	hasAnswered = false;
117  	showExplanation = false;
118  }
119  </script>
120  
121  <div
122  	bind:this={cardRef}
123  	class="my-8 p-4 rounded-xl border-[0.5px] border-pink-200/60 bg-linear-to-bl from-white/70 to-pink-50/20 backdrop-blur-sm shadow-card"
124  	role="region"
125  	aria-label="Quiz"
126  >
127  	<p class="font-body text-pink-950/90 mb-3! leading-relaxed">
128  		{@html question}
129  	</p>
130  
131  	<div class="space-y-2.5" role="radiogroup" aria-label="Answer options">
132  		{#each options as option (option.id)}
133  			<button
134  				type="button"
135  				bind:this={optionRefs[option.id]}
136  				class="w-full text-left p-2 rounded-lg border transition-all duration-200 font-body text-sm
137  					{!hasAnswered
138  					? 'border-dashed border-pink-300 bg-white/60 hover:bg-pink-100/50 hover:border-pink-400 cursor-pointer'
139  					: option.isCorrect
140  						? 'border-2 border-green-400 bg-green-50/70 cursor-default'
141  						: selectedOption === option.id
142  							? 'border-2 border-red-300 bg-red-50/50 cursor-default'
143  							: 'border-dashed border-pink-200/50 bg-white/40 opacity-60 cursor-default'}"
144  				onclick={() => handleSelect(option)}
145  				disabled={hasAnswered}
146  				aria-checked={selectedOption === option.id}
147  				role="radio"
148  			>
149  				<div class="flex items-center gap-3">
150  					<span
151  						class="shrink-0 w-6 h-6 rounded-full border flex items-center justify-center text-xs font-bold
152  						{!hasAnswered
153  							? 'border-pink-300 text-pink-400'
154  							: option.isCorrect
155  								? 'border-green-500 bg-green-500 text-white'
156  								: selectedOption === option.id
157  									? 'border-red-400 bg-red-400 text-white'
158  									: 'border-pink-200 text-pink-300'}"
159  					>
160  						{#if hasAnswered}
161  							{#if option.isCorrect}
162163  							{:else if selectedOption === option.id}
164165  							{:else}
166  								{String.fromCharCode(
167  									65 + options.indexOf(option),
168  								)}
169  							{/if}
170  						{:else}
171  							{String.fromCharCode(65 + options.indexOf(option))}
172  						{/if}
173  					</span>
174  					<span
175  						class={hasAnswered && option.isCorrect
176  							? "text-green-800 font-medium"
177  							: hasAnswered &&
178  								  selectedOption === option.id &&
179  								  !option.isCorrect
180  								? "text-red-700"
181  								: "text-pink-950/80"}
182  					>
183  						{option.text}
184  					</span>
185  				</div>
186  			</button>
187  		{/each}
188  	</div>
189  
190  	{#if showExplanation}
191  		<div
192  			class="mt-5 p-4 border rounded-lg {selectedOption &&
193  			options.find((o) => o.id === selectedOption)?.isCorrect
194  				? 'bg-green-50/70 border-green-400'
195  				: 'bg-red-50/50 border-red-400'}"
196  		>
197  			<p
198  				class="font-body text-sm leading-relaxed mb-0! {selectedOption &&
199  				options.find((o) => o.id === selectedOption)?.isCorrect
200  					? 'text-green-800'
201  					: 'text-red-800'}"
202  			>
203  				{#if selectedOption && options.find((o) => o.id === selectedOption)?.isCorrect}
204  					<span class="font-semibold">{correctMsg.title}</span>
205  					{@html correctMsg.message}
206  				{:else}
207  					<span class="font-semibold">{errorMsg.title}</span>
208  					{@html errorMsg.message}
209  				{/if}
210  			</p>
211  			{#if selectedOption && !options.find((o) => o.id === selectedOption)?.isCorrect}
212  				<button
213  					type="button"
214  					class="mt-2 text-xs font-body text-pink-600 hover:text-pink-800 underline underline-offset-2 cursor-pointer"
215  					onclick={reset}
216  				>
217  					Try again
218  				</button>
219  			{/if}
220  		</div>
221  	{/if}
222  </div>