fix ui package, use sv to create
recreated package using sv, marking correctly as a library. seems I was missing some sveltekit tooling somewhere along the way otherwise.
This commit is contained in:
454
src/lib/StateMachine.svelte
Normal file
454
src/lib/StateMachine.svelte
Normal file
@@ -0,0 +1,454 @@
|
||||
<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 mt-5 mb-5 flex h-8 items-center justify-between lg:mt-0 lg:mb-3
|
||||
{!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 absolute top-8 left-[70%] hidden! -translate-x-1/2 lg:flex!">
|
||||
{@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 right-4 bottom-4 left-4 text-white lg:bottom-1/2 lg:pr-4 lg:text-right',
|
||||
hero.fontSize === 'small'
|
||||
? 'max-h-[7.2rem] text-5xl leading-[3.6rem] font-semibold'
|
||||
: 'max-h-[10.6rem] text-7xl leading-[5.3rem] font-bold'
|
||||
]}
|
||||
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 top-0 right-0 left-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>
|
||||
Reference in New Issue
Block a user