Files
sui/components/StateMachine.svelte
2025-04-13 07:56:23 -07:00

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>