455 lines
12 KiB
Svelte
455 lines
12 KiB
Svelte
<script lang="ts" module>
|
|
export type StateMachinePage = {
|
|
onMount?: () => void;
|
|
disableBack?: boolean;
|
|
hero: {
|
|
text: string | (() => string);
|
|
fontSize: 'small' | 'large';
|
|
size: 'small' | 'large';
|
|
colour: 'light' | 'dark';
|
|
};
|
|
snippet: Snippet;
|
|
button:
|
|
| false
|
|
| {
|
|
text: string;
|
|
icon: string;
|
|
onclick?: MouseEventHandler<HTMLButtonElement>;
|
|
};
|
|
|
|
// a post-submit action to be called after the form is submitted
|
|
// if it returns a string, it marks an error and prevents the step from advancing
|
|
onComplete?: () => void | Promise<void>;
|
|
};
|
|
|
|
type Circle = {
|
|
size: number;
|
|
x: number;
|
|
y: number;
|
|
color: string;
|
|
};
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import { browser } from '$app/environment';
|
|
|
|
import Button from './Button.svelte';
|
|
import { onMount, tick, type Snippet } from 'svelte';
|
|
import { cubicOut } from 'svelte/easing';
|
|
import type { MouseEventHandler } from 'svelte/elements';
|
|
import { tweened } from 'svelte/motion';
|
|
import { fade, fly } from 'svelte/transition';
|
|
import { validateForm } from '@repo/validate';
|
|
|
|
let {
|
|
pages,
|
|
success,
|
|
failure,
|
|
index = $bindable(0),
|
|
action
|
|
}: {
|
|
pages: StateMachinePage[];
|
|
success: StateMachinePage;
|
|
failure: StateMachinePage;
|
|
index?: number;
|
|
action?: string;
|
|
} = $props();
|
|
|
|
pages.push(success, failure); // add success and failure pages to the end of the pages array
|
|
// let progressVisible = $state(false);
|
|
|
|
let lastIndex = $state(index);
|
|
let page = $derived(pages[index]);
|
|
let hero = $derived(page.hero);
|
|
let buttonLoading: boolean = $state(false);
|
|
|
|
let height = tweened(0, {
|
|
duration: 250,
|
|
easing: cubicOut
|
|
});
|
|
|
|
let pageContainer: HTMLDivElement | undefined = $state<HTMLDivElement>();
|
|
let formElement: HTMLFormElement;
|
|
let circleBox: HTMLDivElement;
|
|
let heroText: HTMLSpanElement;
|
|
let buttonContainer: HTMLDivElement | undefined = $state<HTMLDivElement>();
|
|
let buttonComponent: Button | undefined = $state<Button>();
|
|
let collatedFormData: Record<string, string> = {};
|
|
|
|
let backButtonVisible = $derived(index > 0 && !page.disableBack);
|
|
|
|
$effect.pre(() => {
|
|
if (!browser || !pageContainer) return;
|
|
|
|
index = index; // reference the index to trigger the effect when it changes
|
|
|
|
tick().then(() => {
|
|
height.set(pageContainer?.offsetHeight ?? 0);
|
|
updateCircles();
|
|
|
|
// Make sure hero text fits
|
|
heroText.style.fontSize = '';
|
|
heroText.style.lineHeight = '';
|
|
updateHeroText();
|
|
|
|
// Make sure we can see the button
|
|
updateScrollHeight();
|
|
|
|
// Call onmount if it exists
|
|
if (page.onMount) page.onMount();
|
|
});
|
|
});
|
|
|
|
const updateHeroText = () => {
|
|
if (
|
|
heroText.scrollWidth > heroText.offsetWidth ||
|
|
heroText.scrollHeight > heroText.offsetHeight
|
|
) {
|
|
const fontSize = parseInt(window.getComputedStyle(heroText).fontSize);
|
|
heroText.style.fontSize = `${fontSize - 5}px`;
|
|
heroText.style.lineHeight = `${(fontSize - 5) * 1.2}px`;
|
|
|
|
updateHeroText();
|
|
}
|
|
};
|
|
|
|
const updateScrollHeight = () => {
|
|
if (!buttonContainer) return;
|
|
const bottom = buttonContainer.getBoundingClientRect().bottom;
|
|
const height = window.innerHeight;
|
|
|
|
window.scrollTo({ top: bottom - height + 32, behavior: 'smooth' });
|
|
};
|
|
|
|
const handleContinueClick: MouseEventHandler<HTMLButtonElement> = async (event) => {
|
|
if (!buttonComponent) return; // what in the ghost
|
|
|
|
buttonComponent.animateRipple(event);
|
|
|
|
// validate the form
|
|
const result = await validateForm(formElement);
|
|
console.log('validated form', result);
|
|
if (!result) {
|
|
buttonComponent.animateBounce();
|
|
setTimeout(() => focusFirstInput(true), 50);
|
|
return;
|
|
}
|
|
|
|
// we don't care about the result of onComplete
|
|
// just that it resolves without error
|
|
const res = await Promise.resolve(page.onComplete?.())
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
|
|
if (!res) {
|
|
buttonComponent.animateBounce();
|
|
return;
|
|
}
|
|
|
|
// collate form data for eventual submission
|
|
const formData = new FormData(formElement);
|
|
|
|
for (const [key, value] of formData.entries()) {
|
|
collatedFormData[key] = value.toString();
|
|
}
|
|
|
|
// bubble up the event
|
|
if (page.button) page.button.onclick?.(event);
|
|
if (event.defaultPrevented) return;
|
|
|
|
// if this is the last page, submit the form
|
|
if (index === pages.length - 3) {
|
|
submitCollatedData();
|
|
} else {
|
|
buttonComponent.animateLoop();
|
|
index += 1;
|
|
setTimeout(() => focusFirstInput(), 350);
|
|
}
|
|
};
|
|
|
|
const getText = (text: string | (() => string)) => {
|
|
return typeof text === 'function' ? text() : text;
|
|
};
|
|
|
|
let dirquery = 0;
|
|
const dir = (val: number) => {
|
|
let result = 1;
|
|
if (index < lastIndex) {
|
|
result = -1;
|
|
dirquery = result;
|
|
} else if (dirquery !== 0) {
|
|
result = dirquery;
|
|
dirquery = 0;
|
|
}
|
|
lastIndex = index;
|
|
if (dirquery > 1) dirquery = 0;
|
|
return result * val;
|
|
};
|
|
|
|
const flyIn = (node: Element) => {
|
|
return fly(node, { x: dir(200), duration: 200, delay: 225 });
|
|
};
|
|
|
|
const flyOut = (node: Element) => {
|
|
return fly(node, { x: dir(-200), duration: 200 });
|
|
};
|
|
|
|
const focusFirstInput = (firstInvalid?: boolean) => {
|
|
let selector = 'input:not([type="hidden"])';
|
|
if (firstInvalid) selector = 'input[data-validate-state="invalid"]:not([type="hidden"])';
|
|
|
|
const inputs = formElement.querySelectorAll(selector);
|
|
if (inputs.length > 0) {
|
|
const input = inputs[0] as HTMLInputElement;
|
|
input.focus();
|
|
}
|
|
};
|
|
|
|
const submitCollatedData = async () => {
|
|
if (!action) action = window.location.href;
|
|
|
|
// update button state
|
|
buttonLoading = true;
|
|
|
|
const form_data = new FormData();
|
|
for (const [key, value] of Object.entries(collatedFormData)) {
|
|
form_data.append(key, value);
|
|
}
|
|
|
|
const response = await fetch(action, {
|
|
method: 'POST',
|
|
headers: {
|
|
accept: 'application/json',
|
|
'x-sveltekit-action': 'true'
|
|
},
|
|
cache: 'no-store',
|
|
body: form_data
|
|
});
|
|
|
|
buttonLoading = false;
|
|
|
|
const json = await response.json();
|
|
|
|
if (json.status && json.status === 200) {
|
|
index = pages.length - 2;
|
|
} else {
|
|
index = pages.length - 1;
|
|
console.log(json);
|
|
}
|
|
};
|
|
|
|
function getRandomColor() {
|
|
var letters = '0123456789ABCDEF';
|
|
var color = '#';
|
|
for (var i = 0; i < 6; i++) {
|
|
color += letters[Math.floor(Math.random() * 16)];
|
|
}
|
|
return color;
|
|
}
|
|
|
|
const randomFromInterval = (min: number, max: number) => {
|
|
return Math.floor(Math.random() * (max - min + 1) + min);
|
|
};
|
|
|
|
const circles: Circle[][] = [];
|
|
|
|
const updateCircles = () => {
|
|
const elems = circleBox.querySelectorAll('.circle-initial');
|
|
elems.forEach((circle, key) => {
|
|
const el = circle as HTMLDivElement;
|
|
el.style.width = `${circles[index][key].size}rem`;
|
|
el.style.height = el.style.width;
|
|
el.style.top = `${circles[index][key].y}%`;
|
|
el.style.left = `${circles[index][key].x}%`;
|
|
el.style.backgroundColor = circles[index][key].color;
|
|
});
|
|
};
|
|
|
|
onMount(() => {
|
|
// pages.push(success, failure); // add success and failure pages to the end of the pages array
|
|
// progressVisible = true;
|
|
|
|
for (let i = 0; i < 15; i++) {
|
|
for (let j = 0; j < pages.length; j++) {
|
|
if (!circles[j]) circles[j] = [];
|
|
|
|
circles[j].push({
|
|
size: randomFromInterval(8, 15),
|
|
x: randomFromInterval(0, 100),
|
|
y: randomFromInterval(0, 100),
|
|
color: getRandomColor()
|
|
});
|
|
}
|
|
|
|
const div = document.createElement('div');
|
|
div.style.borderRadius = '100%';
|
|
div.style.position = 'absolute';
|
|
div.style.transition =
|
|
'background-color 0.5s, opacity 1s, top 1s, left 1s, width 1s, height 1s';
|
|
div.style.width = `${circles[index][i].size}rem`;
|
|
div.style.height = div.style.width;
|
|
div.style.top = `${circles[index][i].y}%`;
|
|
div.style.left = `${circles[index][i].x}%`;
|
|
div.style.backgroundColor = circles[index][i].color;
|
|
div.classList.add('circle-initial');
|
|
circleBox.appendChild(div);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
const circles = circleBox.querySelectorAll('.circle-initial');
|
|
circles.forEach((circle) => {
|
|
circle.classList.add('circle-final');
|
|
});
|
|
}, 1000);
|
|
|
|
setTimeout(() => focusFirstInput(), 50);
|
|
});
|
|
</script>
|
|
|
|
<!-- Form progress bar -->
|
|
{#snippet progress()}
|
|
{#each { length: pages.length - 2 } as _, i}
|
|
<div
|
|
class="relative flex h-6 w-7 items-center justify-center rounded-3xl bg-white
|
|
text-sm dark:bg-slate-600 {index === i
|
|
? 'bg-accent-500! text-background dark:bg-accent-700!'
|
|
: ''} {i < index ? 'text-background bg-green-600!' : ''} {i > index
|
|
? 'scale-[0.85] opacity-80'
|
|
: ''}
|
|
transition-[transform,background-color,color]"
|
|
>
|
|
{#if i >= index}
|
|
<span class="mb-[0.0625rem]">{i + 1}</span>
|
|
{:else}
|
|
<span class="material-symbols-outlined mt-0.5 text-2xl">check_small</span>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{/snippet}
|
|
|
|
<div
|
|
class="transition-height relative mb-5 mt-5 flex h-8 items-center justify-between lg:mb-3 lg:mt-0
|
|
{!backButtonVisible ? 'lg:h-0' : ''}"
|
|
>
|
|
<!-- Back button -->
|
|
{#if backButtonVisible}
|
|
<button
|
|
class="text-text hover:text-text-700 dark:text-background dark:hover:text-background/80 flex items-center gap-2.5
|
|
font-medium transition-colors"
|
|
onclick={() => (index -= 1)}
|
|
transition:fly={{ x: -200, duration: 200 }}
|
|
>
|
|
<span class="material-symbols-outlined text-base">arrow_back</span>
|
|
Back
|
|
</button>
|
|
{/if}
|
|
|
|
<!-- Progress bar (mobile only, above form) -->
|
|
<div
|
|
class="progress absolute transition-transform duration-500 lg:!hidden {backButtonVisible
|
|
? 'right-0'
|
|
: 'left-1/2 -translate-x-1/2'}"
|
|
>
|
|
{@render progress()}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Form container -->
|
|
<div
|
|
class="sm:shadow-centre relative flex flex-col rounded-md sm:bg-white/85 lg:h-auto
|
|
lg:max-h-[60rem] lg:min-h-[30rem] lg:flex-row lg:items-center dark:sm:bg-gray-900"
|
|
>
|
|
<!-- Progress bar (desktop only, in form space) -->
|
|
<div class="progress hidden! lg:flex! absolute left-[70%] top-8 -translate-x-1/2">
|
|
{@render progress()}
|
|
</div>
|
|
|
|
<!-- Hero container -->
|
|
<div
|
|
class={[
|
|
'hero group relative mb-5 min-w-36 basis-2/5 self-stretch overflow-hidden rounded-md duration-500',
|
|
'sm:rounded-b-none lg:mb-0 lg:rounded-md lg:rounded-r-none',
|
|
hero.size === 'small' && 'min-h-52',
|
|
hero.size === 'large' && 'min-h-72'
|
|
]}
|
|
>
|
|
<!-- Circle decoration -->
|
|
<div class="absolute inset-0 bg-gray-600 dark:bg-gray-900" bind:this={circleBox}></div>
|
|
<div
|
|
class={[
|
|
'absolute inset-0 backdrop-blur-md transition-colors',
|
|
hero.colour === 'light' ? 'bg-accent/80' : 'bg-[#00283C]/80'
|
|
]}
|
|
></div>
|
|
|
|
<!-- Hero text -->
|
|
<span
|
|
class={[
|
|
'absolute bottom-4 left-4 right-4 text-white lg:bottom-1/2 lg:pr-4 lg:text-right',
|
|
hero.fontSize === 'small'
|
|
? 'max-h-[7.2rem] text-5xl font-semibold leading-[3.6rem]'
|
|
: 'max-h-[10.6rem] text-7xl font-bold leading-[5.3rem]'
|
|
]}
|
|
transition:fade
|
|
bind:this={heroText}
|
|
>
|
|
{getText(hero.text)}
|
|
</span>
|
|
</div>
|
|
|
|
<form
|
|
class="basis-3/5 sm:overflow-hidden sm:px-6 sm:pb-6 lg:mt-24 lg:px-12"
|
|
novalidate
|
|
onsubmit={(e) => e.preventDefault()}
|
|
bind:this={formElement}
|
|
>
|
|
<div class="relative" style="height: {$height}px">
|
|
<!-- Form page -->
|
|
{#key index}
|
|
<div
|
|
class="text-text dark:text-background absolute left-0 right-0 top-0"
|
|
in:flyIn
|
|
out:flyOut
|
|
bind:this={pageContainer}
|
|
>
|
|
{@render page.snippet()}
|
|
</div>
|
|
{/key}
|
|
</div>
|
|
|
|
<!-- Continue / submit button -->
|
|
{#if index < pages.length - 1 && page.button && $height > 0}
|
|
<div class="mt-4" out:fade bind:this={buttonContainer}>
|
|
<Button
|
|
icon={page.button.icon}
|
|
onclick={handleContinueClick}
|
|
animate={false}
|
|
loading={buttonLoading}
|
|
bind:this={buttonComponent}
|
|
>
|
|
{page.button.text}
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
</form>
|
|
</div>
|
|
|
|
<style lang="postcss">
|
|
@reference "@repo/tailwindcss-config/app.css";
|
|
|
|
:global(.circle-initial) {
|
|
transition: opacity 1s;
|
|
opacity: 0;
|
|
}
|
|
|
|
:global(.circle-final) {
|
|
opacity: 1;
|
|
}
|
|
|
|
.progress {
|
|
@apply flex cursor-default items-center justify-center gap-3 overflow-hidden rounded-3xl px-3 py-2;
|
|
@apply dark:bg-text-800 bg-gray-200/80 dark:lg:bg-slate-800;
|
|
@apply text-text/60 dark:text-background/60 font-semibold;
|
|
}
|
|
</style>
|