partially refactor components to ui package
This commit is contained in:
150
components/Button.svelte
Normal file
150
components/Button.svelte
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { MouseEventHandler } from 'svelte/elements';
|
||||||
|
import Spinner from './Spinner.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
icon,
|
||||||
|
animate = true,
|
||||||
|
loading,
|
||||||
|
children,
|
||||||
|
onclick
|
||||||
|
}: {
|
||||||
|
icon?: string;
|
||||||
|
animate?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
children: Snippet;
|
||||||
|
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let iconElement = $state<HTMLSpanElement | null>(null);
|
||||||
|
|
||||||
|
const handleButtonClick: MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||||
|
if (animate) {
|
||||||
|
animateLoop();
|
||||||
|
animateRipple(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
onclick?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerAnimation = (className: string) => {
|
||||||
|
if (icon && iconElement) {
|
||||||
|
iconElement.classList.remove(className);
|
||||||
|
void iconElement.offsetWidth;
|
||||||
|
iconElement.classList.add(className);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const animateLoop = () => triggerAnimation('animate');
|
||||||
|
export const animateBounce = () => triggerAnimation('bounce');
|
||||||
|
export const animateRipple: MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||||
|
const button = event.currentTarget;
|
||||||
|
const circle = document.createElement('span');
|
||||||
|
const diameter = Math.max(button.clientWidth, button.clientHeight);
|
||||||
|
const radius = diameter / 2;
|
||||||
|
|
||||||
|
const rect = button.getBoundingClientRect();
|
||||||
|
const x = event.clientX - rect.left - radius;
|
||||||
|
const y = event.clientY - rect.top - radius;
|
||||||
|
|
||||||
|
circle.style.width = circle.style.height = `${diameter}px`;
|
||||||
|
circle.style.left = `${x}px`;
|
||||||
|
circle.style.top = `${y}px`;
|
||||||
|
circle.classList.add('ripple');
|
||||||
|
|
||||||
|
const ripples = button.getElementsByClassName('ripple');
|
||||||
|
for (let i = 0; i < ripples.length; i++) {
|
||||||
|
ripples[i].remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
button.appendChild(circle);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class={[
|
||||||
|
'button group relative flex gap-3 overflow-hidden rounded-sm px-5',
|
||||||
|
'text-background py-3 font-medium transition-colors',
|
||||||
|
!loading ? ' bg-primary hover:bg-secondary' : 'bg-primary/50 cursor-not-allowed '
|
||||||
|
]}
|
||||||
|
onclick={handleButtonClick}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
|
||||||
|
{#if icon && !loading}
|
||||||
|
<span class="material-symbols-outlined" bind:this={iconElement}>{icon}</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="w-[1rem]"></div>
|
||||||
|
<div class="absolute right-4 top-1/2 translate-y-[-40%]"><Spinner size="1.3rem" /></div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes loop {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 100%;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 100%;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateX(3rem);
|
||||||
|
opacity: 0%;
|
||||||
|
}
|
||||||
|
51% {
|
||||||
|
transform: translateX(-2rem);
|
||||||
|
opacity: 0%;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateX(-0.25rem);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateX(0.25rem);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ripple {
|
||||||
|
to {
|
||||||
|
transform: scale(4);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(button span.animate) {
|
||||||
|
animation-name: loop;
|
||||||
|
animation-duration: 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(button span.bounce) {
|
||||||
|
animation-name: bounce;
|
||||||
|
animation-duration: 180ms;
|
||||||
|
animation-timing-function: ease-in-out;
|
||||||
|
animation-iteration-count: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(span.ripple) {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
animation: ripple 0.5s linear;
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
components/CenterBox.svelte
Normal file
11
components/CenterBox.svelte
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { children }: { children: Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen items-center justify-center sm:flex">
|
||||||
|
<main class="m-4 max-w-(--breakpoint-sm) sm:mb-[5%] sm:w-full lg:max-w-[950px]">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
467
components/Combobox.svelte
Normal file
467
components/Combobox.svelte
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
export type ComboboxItem = {
|
||||||
|
value: string;
|
||||||
|
label?: string;
|
||||||
|
infotext?: string;
|
||||||
|
preview?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
icon?: Snippet<[item: ComboboxItem]>;
|
||||||
|
render?: Snippet<[item: ComboboxItem]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ComboboxChangeEvent = {
|
||||||
|
value: ComboboxItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLabel = (item: ComboboxItem | undefined): string => item?.label ?? item?.value ?? '';
|
||||||
|
const getPreview = (item: ComboboxItem | undefined): string => item?.preview ?? getLabel(item);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { CaretUpDown, Check } from 'phosphor-svelte';
|
||||||
|
|
||||||
|
import Label from './Label.svelte';
|
||||||
|
import StyledRawInput from './StyledRawInput.svelte';
|
||||||
|
import { InputValidatorEvent, validate, type ValidatorOptions } from '@repo/validate';
|
||||||
|
import type { Action } from 'svelte/action';
|
||||||
|
import { onMount, tick, untrack, type Snippet } from 'svelte';
|
||||||
|
import { Portal } from '@jsrob/svelte-portal';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { scale } from 'svelte/transition';
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
value = $bindable<ComboboxItem | undefined>(undefined),
|
||||||
|
open = $bindable(false),
|
||||||
|
usePreview = 'auto',
|
||||||
|
matchWidth = false,
|
||||||
|
items,
|
||||||
|
required = false,
|
||||||
|
invalidMessage,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
notFoundMessage = 'No results found',
|
||||||
|
onvalidate,
|
||||||
|
onchange
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
value?: ComboboxItem;
|
||||||
|
highlighted?: ComboboxItem;
|
||||||
|
open?: boolean;
|
||||||
|
usePreview?: boolean | 'auto';
|
||||||
|
matchWidth?: boolean;
|
||||||
|
items: ComboboxItem[];
|
||||||
|
required?: boolean;
|
||||||
|
invalidMessage?: string;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
notFoundMessage?: string;
|
||||||
|
onvalidate?: (e: InputValidatorEvent) => void;
|
||||||
|
onchange?: (e: ComboboxChangeEvent) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let valid = $state(true);
|
||||||
|
let searchValue = $state('');
|
||||||
|
let pickerPosition = $state<'top' | 'bottom'>('bottom');
|
||||||
|
let searching = $state(false);
|
||||||
|
|
||||||
|
let searchInput = $state<HTMLInputElement | null>(null);
|
||||||
|
let searchContainer = $state<HTMLDivElement | null>(null);
|
||||||
|
let pickerContainer = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const filteredItems = $derived(
|
||||||
|
searchValue === ''
|
||||||
|
? items
|
||||||
|
: items.filter((item) => getLabel(item).toLowerCase().includes(searchValue.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
let highlighted = $derived.by((): ComboboxItem | undefined => {
|
||||||
|
if (filteredItems.length === 0) return undefined;
|
||||||
|
if (value !== undefined && filteredItems.find((v) => v.value === value?.value)) return value;
|
||||||
|
return filteredItems[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
let iconVisible = $derived.by(() => {
|
||||||
|
return (open && highlighted && highlighted.icon) || (value && value.icon && searchValue === '');
|
||||||
|
});
|
||||||
|
let useHighlighted = $derived.by(() => {
|
||||||
|
return open && highlighted;
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateOpts: ValidatorOptions = { required };
|
||||||
|
|
||||||
|
const getHightlightedID = () => {
|
||||||
|
if (highlighted === undefined) return -1;
|
||||||
|
return filteredItems.findIndex((item) => item.value === highlighted?.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const minWidth: Action<HTMLDivElement, { items: ComboboxItem[] }> = (container, buildOpts) => {
|
||||||
|
const f = (opts: typeof buildOpts) => {
|
||||||
|
if (matchWidth && searchInput) {
|
||||||
|
container.style.width = searchInput.scrollWidth + 'px';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = opts.items;
|
||||||
|
const avg = items.reduce((acc, item) => acc + getLabel(item).length, 0) / items.length;
|
||||||
|
container.style.width = `${avg * 2.5}ch`;
|
||||||
|
console.log(`minWidth: ${avg * 2.5}ch`);
|
||||||
|
};
|
||||||
|
|
||||||
|
f(buildOpts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update: (updateOpts: typeof buildOpts) => {
|
||||||
|
f(updateOpts);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePickerRect = async () => {
|
||||||
|
if (!searchContainer || !pickerContainer) {
|
||||||
|
await tick();
|
||||||
|
if (!searchContainer || !pickerContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlay = pickerContainer;
|
||||||
|
const target = searchContainer;
|
||||||
|
const targetRect = target.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// choose whether the overlay should be above or below the target
|
||||||
|
const availableSpaceBelow = window.innerHeight - targetRect.bottom;
|
||||||
|
const availableSpaceAbove = targetRect.top;
|
||||||
|
const outerMargin = 24;
|
||||||
|
|
||||||
|
if (availableSpaceBelow < availableSpaceAbove) {
|
||||||
|
overlay.style.bottom = `${window.innerHeight - targetRect.top}px`;
|
||||||
|
overlay.style.top = 'auto';
|
||||||
|
overlay.style.maxHeight = `${availableSpaceAbove - outerMargin}px`;
|
||||||
|
pickerPosition = 'top';
|
||||||
|
overlay.dataset.side = 'top';
|
||||||
|
} else {
|
||||||
|
overlay.style.top = `${targetRect.bottom}px`;
|
||||||
|
overlay.style.bottom = 'auto';
|
||||||
|
overlay.style.maxHeight = `${availableSpaceBelow - outerMargin}px`;
|
||||||
|
pickerPosition = 'bottom';
|
||||||
|
overlay.dataset.side = 'bottom';
|
||||||
|
}
|
||||||
|
|
||||||
|
// set overlay left position
|
||||||
|
overlay.style.left = `${targetRect.left}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToHighlighted = () => {
|
||||||
|
if (!pickerContainer || !highlighted) return;
|
||||||
|
const highlightedElement = pickerContainer.querySelector(`[data-id="${highlighted.value}"]`);
|
||||||
|
if (!highlightedElement) return;
|
||||||
|
|
||||||
|
const rect = highlightedElement.getBoundingClientRect();
|
||||||
|
const pickerRect = pickerContainer.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (rect.top - 20 < pickerRect.top) {
|
||||||
|
pickerContainer.scrollTop -= pickerRect.top - rect.top + 20;
|
||||||
|
} else if (rect.bottom + 20 > pickerRect.bottom) {
|
||||||
|
pickerContainer.scrollTop += rect.bottom - pickerRect.bottom + 20;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const focus = () => {
|
||||||
|
searchInput?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
// update picker position on window resize
|
||||||
|
window.addEventListener('resize', updatePickerRect);
|
||||||
|
|
||||||
|
// add window click listener to close picker
|
||||||
|
window.addEventListener('click', (e) => {
|
||||||
|
if (!searchContainer || !pickerContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
searchContainer.contains(e.target as Node) ||
|
||||||
|
pickerContainer.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
open = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// when the picker is opened, update its position
|
||||||
|
// when the picker is closed, clear any search value (next time it opens, all items will be shown)
|
||||||
|
// when the picker is closed, reset highlighted to the currently selected value (if any)
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
updatePickerRect();
|
||||||
|
scrollToHighlighted();
|
||||||
|
} else {
|
||||||
|
searchValue = '';
|
||||||
|
searching = false;
|
||||||
|
highlighted = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// when the value (or, in some circumstances, highlighted item) changes, update the search input
|
||||||
|
$effect(() => {
|
||||||
|
if (!searchInput) return;
|
||||||
|
|
||||||
|
if (useHighlighted && !searching) {
|
||||||
|
searchInput.value = getLabel(highlighted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (untrack(() => searching)) return;
|
||||||
|
|
||||||
|
if (!usePreview || (usePreview === 'auto' && open)) {
|
||||||
|
searchInput.value = getLabel(value);
|
||||||
|
} else {
|
||||||
|
searchInput.value = getPreview(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// when highlighted changes, scroll to it
|
||||||
|
$effect(() => {
|
||||||
|
if (highlighted) {
|
||||||
|
scrollToHighlighted();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
updatePickerRect();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Portal target="body">
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
'picker absolute left-0 top-0 z-50 overflow-y-auto px-2 py-3',
|
||||||
|
'outline-hidden rounded-sm border shadow-lg shadow-black/25',
|
||||||
|
'border-accent dark:border-accent/50 dark:bg-text-800 bg-white dark:sm:bg-slate-800',
|
||||||
|
'text-text dark:text-background',
|
||||||
|
open && pickerPosition === 'top' && 'mb-[var(--outer-gap)]',
|
||||||
|
open && pickerPosition === 'bottom' && 'mt-[var(--outer-gap)]'
|
||||||
|
]}
|
||||||
|
use:minWidth={{ items: items }}
|
||||||
|
bind:this={pickerContainer}
|
||||||
|
transition:scale={{ duration: 200 }}
|
||||||
|
role="listbox"
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
open = false;
|
||||||
|
searchInput?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
{#each filteredItems as item, i (i + item.value)}
|
||||||
|
<div
|
||||||
|
data-id={item.value}
|
||||||
|
aria-selected={value?.value === item.value}
|
||||||
|
aria-label={getLabel(item)}
|
||||||
|
aria-disabled={item.disabled}
|
||||||
|
class={[
|
||||||
|
'picker-item mb-0.5 flex min-h-10 flex-wrap items-center py-2.5 pl-5 pr-1.5',
|
||||||
|
'outline-hidden select-none rounded-sm text-sm capitalize',
|
||||||
|
'hover:bg-accent-500/30 dark:hover:bg-accent-700/30',
|
||||||
|
item.value === highlighted?.value && 'bg-accent-500/80 dark:bg-accent-700/80'
|
||||||
|
]}
|
||||||
|
role="option"
|
||||||
|
onclick={() => {
|
||||||
|
value = item;
|
||||||
|
open = false;
|
||||||
|
searchInput?.focus();
|
||||||
|
onchange?.({ value: item });
|
||||||
|
}}
|
||||||
|
onkeydown={() => {}}
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{#if item.icon}
|
||||||
|
{@render item.icon(item)}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class={['mr-8', item.icon && 'ml-2']}>
|
||||||
|
{#if item.render}
|
||||||
|
{@render item.render(item)}
|
||||||
|
{:else}
|
||||||
|
{getLabel(item)}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if item?.infotext}
|
||||||
|
<div class="text-text/80 dark:text-background/80 ml-auto text-sm">
|
||||||
|
{item.infotext}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if value?.value === item.value}
|
||||||
|
<div class={[item?.infotext ? 'ml-2' : 'ml-auto']}>
|
||||||
|
<Check />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="block px-5 py-2 text-sm">{notFoundMessage}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Portal>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<!-- Combobox Label -->
|
||||||
|
{#if label}
|
||||||
|
<Label for={name}>{label}</Label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Hidden input stores currently selected value -->
|
||||||
|
<input
|
||||||
|
{name}
|
||||||
|
value={value?.value ?? ''}
|
||||||
|
class="hidden"
|
||||||
|
use:validate={validateOpts}
|
||||||
|
onvalidate={(e) => {
|
||||||
|
valid = e.detail.valid;
|
||||||
|
onvalidate?.(e);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Search input -->
|
||||||
|
<div bind:this={searchContainer}>
|
||||||
|
{@render searchInputBox()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message if invalid -->
|
||||||
|
{#if invalidMessage}
|
||||||
|
<div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}>
|
||||||
|
<Label for={name} error>{invalidMessage}</Label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#snippet searchInputBox(caret: boolean = true)}
|
||||||
|
<div class="relative">
|
||||||
|
{#if iconVisible}
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
'pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 transform select-none'
|
||||||
|
]}
|
||||||
|
transition:scale
|
||||||
|
>
|
||||||
|
{#if useHighlighted && highlighted?.icon}
|
||||||
|
{@render highlighted.icon(highlighted)}
|
||||||
|
{:else if value?.icon}
|
||||||
|
{@render value.icon(value)}
|
||||||
|
{:else}
|
||||||
|
❌
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<StyledRawInput
|
||||||
|
class={[iconVisible && 'pl-10', caret && 'pr-9', !valid && 'border-red-500!']}
|
||||||
|
type="text"
|
||||||
|
name={name + '_search'}
|
||||||
|
{placeholder}
|
||||||
|
autocomplete="off"
|
||||||
|
bind:ref={searchInput}
|
||||||
|
onclick={() => {
|
||||||
|
if (!open) {
|
||||||
|
setTimeout(() => {
|
||||||
|
searchInput?.select();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
open = true;
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (!searchInput) return;
|
||||||
|
|
||||||
|
if (e.key === 'Tab' || e.key === 'Enter') {
|
||||||
|
if (open && highlighted && highlighted.value !== value?.value) {
|
||||||
|
value = highlighted;
|
||||||
|
onchange?.({ value: highlighted });
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
open = false;
|
||||||
|
return;
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
open = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// open the picker
|
||||||
|
open = true;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
searching = false;
|
||||||
|
console.log('arrowNavOnly = true');
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
const nextIndex = getHightlightedID() + 1;
|
||||||
|
if (nextIndex < filteredItems.length) {
|
||||||
|
highlighted = filteredItems[nextIndex];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
const prevIndex = getHightlightedID() - 1;
|
||||||
|
if (prevIndex >= 0) {
|
||||||
|
highlighted = filteredItems[prevIndex];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
oninput={() => {
|
||||||
|
if (!searchInput) return;
|
||||||
|
searchValue = searchInput.value;
|
||||||
|
searching = true;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if (value && value.infotext) || (highlighted && useHighlighted && highlighted.infotext)}
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
'pointer-events-none absolute top-1/2 -translate-y-1/2 transform select-none text-sm',
|
||||||
|
'text-text/80 dark:text-background/80',
|
||||||
|
caret ? 'end-10' : 'end-[1.125rem]'
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{useHighlighted && highlighted?.infotext ? highlighted.infotext : value?.infotext}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if caret}
|
||||||
|
<CaretUpDown
|
||||||
|
class="absolute end-2.5 top-1/2 size-6 -translate-y-1/2"
|
||||||
|
onclick={() => {
|
||||||
|
open = !open;
|
||||||
|
if (open) searchInput?.focus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
@reference "@repo/tailwindcss-config/app.css";
|
||||||
|
|
||||||
|
.picker {
|
||||||
|
--outer-gap: 0.5rem;
|
||||||
|
@apply max-h-[calc(100vh-var(--outer-gap))];
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
components/FramelessButton.svelte
Normal file
40
components/FramelessButton.svelte
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { MouseEventHandler } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
icon,
|
||||||
|
iconPosition = 'right',
|
||||||
|
disabled = false,
|
||||||
|
children,
|
||||||
|
onclick
|
||||||
|
}: {
|
||||||
|
icon?: string;
|
||||||
|
iconPosition?: 'left' | 'right';
|
||||||
|
disabled?: boolean;
|
||||||
|
children: Snippet;
|
||||||
|
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet iconSnippet()}
|
||||||
|
<span class="material-symbols-outlined">{icon}</span>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class={[
|
||||||
|
'text-accent hover:text-primary inline-flex items-center gap-1.5 transition-colors',
|
||||||
|
disabled && 'pointer-events-none cursor-not-allowed opacity-50'
|
||||||
|
]}
|
||||||
|
{onclick}
|
||||||
|
>
|
||||||
|
{#if icon && iconPosition === 'left'}
|
||||||
|
{@render iconSnippet()}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
|
|
||||||
|
{#if icon && iconPosition === 'right'}
|
||||||
|
{@render iconSnippet()}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
23
components/Label.svelte
Normal file
23
components/Label.svelte
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
for: target,
|
||||||
|
error,
|
||||||
|
bigError,
|
||||||
|
children
|
||||||
|
}: { for: string; error?: boolean; bigError?: boolean; children: Snippet } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
for={target}
|
||||||
|
class={[
|
||||||
|
'transition-fontColor block',
|
||||||
|
error && !bigError
|
||||||
|
? 'mt-1 text-sm font-normal text-red-500'
|
||||||
|
: 'text-text dark:text-background mb-3 text-base font-medium',
|
||||||
|
bigError && 'text-red-500!'
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</label>
|
||||||
57
components/Link.svelte
Normal file
57
components/Link.svelte
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
const { PUBLIC_BASEPATH } = env;
|
||||||
|
|
||||||
|
const trim = (str: string, char: string, trimStart?: boolean, trimEnd?: boolean) => {
|
||||||
|
let start = 0,
|
||||||
|
end = str.length;
|
||||||
|
|
||||||
|
if (trimStart || trimStart === undefined) {
|
||||||
|
while (start < end && str[start] === char) start++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimEnd || trimEnd === undefined) {
|
||||||
|
while (end > start && str[end - 1] === char) end--;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str.substring(start, end);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { MouseEventHandler } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
href,
|
||||||
|
disabled = false,
|
||||||
|
tab = 'current',
|
||||||
|
children,
|
||||||
|
onclick
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
tab?: 'current' | 'new';
|
||||||
|
children: Snippet;
|
||||||
|
onclick?: MouseEventHandler<HTMLAnchorElement>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
if (PUBLIC_BASEPATH && !href.startsWith('http://') && !href.startsWith('https://')) {
|
||||||
|
let prefix = trim(PUBLIC_BASEPATH, '/');
|
||||||
|
href = `/${prefix}/${trim(href, '/', true, false)}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class={[
|
||||||
|
'text-accent hover:text-primary inline-flex items-center gap-1.5 transition-colors',
|
||||||
|
disabled && 'pointer-events-none cursor-not-allowed opacity-50'
|
||||||
|
]}
|
||||||
|
{href}
|
||||||
|
target={tab === 'new' ? '_blank' : undefined}
|
||||||
|
rel={tab === 'new' ? 'noopener noreferrer' : undefined}
|
||||||
|
{onclick}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</a>
|
||||||
174
components/PhoneInput.svelte
Normal file
174
components/PhoneInput.svelte
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Label from './Label.svelte';
|
||||||
|
import { Country, type ICountry } from 'country-state-city';
|
||||||
|
import ExpandableCombobox, { type ComboboxItem } from './Combobox.svelte';
|
||||||
|
import StyledRawInput from './StyledRawInput.svelte';
|
||||||
|
import { AsYouType, type PhoneNumber, type CountryCode } from 'libphonenumber-js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
value = $bindable<PhoneNumber | undefined>(undefined),
|
||||||
|
number = $bindable(''), // consider making this private, it should only be used internally
|
||||||
|
country = $bindable<ICountry | undefined>(), // consider making this private, it should only be used internally
|
||||||
|
defaultISO,
|
||||||
|
required = false,
|
||||||
|
label,
|
||||||
|
placeholder = 'Phone number',
|
||||||
|
invalidMessage
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
value?: PhoneNumber;
|
||||||
|
number?: string;
|
||||||
|
country?: ICountry;
|
||||||
|
defaultISO?: string;
|
||||||
|
required?: boolean;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
invalidMessage?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let selectedCountryItem = $state<ComboboxItem | undefined>();
|
||||||
|
let countriesOpen = $state<boolean>(false);
|
||||||
|
let countriesValid = $state<boolean>(true);
|
||||||
|
let numberValid = $state<boolean>(true);
|
||||||
|
|
||||||
|
const countries = Country.getAllCountries();
|
||||||
|
const countrycodeMap = countries.reduce(
|
||||||
|
(acc, country) => {
|
||||||
|
acc[country.isoCode] = country;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, ICountry>
|
||||||
|
);
|
||||||
|
const options: ComboboxItem[] = countries.map((country) => ({
|
||||||
|
value: country.isoCode,
|
||||||
|
label: `${country.name} (+${country.phonecode})`,
|
||||||
|
preview: `+${country.phonecode}`,
|
||||||
|
icon: renderIcon
|
||||||
|
}));
|
||||||
|
|
||||||
|
let phonecode: string = $derived.by(() => {
|
||||||
|
if (country) {
|
||||||
|
return countrycodeMap[country.isoCode]?.phonecode ?? '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// setCountryByISO sets the country to match a given ISO code
|
||||||
|
export const setCountryByISO = (iso: string) => {
|
||||||
|
if (iso in countrycodeMap) {
|
||||||
|
countriesOpen = false;
|
||||||
|
country = countrycodeMap[iso];
|
||||||
|
selectedCountryItem = options.find((item) => item.value === country?.isoCode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// set the default country based on the provided ISO code
|
||||||
|
if (defaultISO) {
|
||||||
|
setCountryByISO(defaultISO);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if country is still blank (probably invalid defaultISO), set it to the first country
|
||||||
|
if (!country) {
|
||||||
|
country = countries[0];
|
||||||
|
|
||||||
|
if (defaultISO)
|
||||||
|
console.warn(`PhoneInput: Invalid defaultISO "${defaultISO}", defaulting to ${country.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedCountryItem = options.find((item) => item.value === country?.isoCode);
|
||||||
|
|
||||||
|
let formatterCountryCode: CountryCode | undefined = $derived.by(() => {
|
||||||
|
if (country) return country.isoCode as CountryCode;
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet renderIcon(item: ComboboxItem)}
|
||||||
|
{#if countrycodeMap[item.value]?.flag}
|
||||||
|
{countrycodeMap[item.value].flag}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#if label}
|
||||||
|
<Label for={name}>{label}</Label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Hidden input stores international E.164 formatted number -->
|
||||||
|
<input type="hidden" {name} value={value?.number ?? ''} />
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div
|
||||||
|
class={['transition-width', countriesOpen && 'w-full!']}
|
||||||
|
style="width: {phonecode.length * 1.5 + 12.5}ch;"
|
||||||
|
>
|
||||||
|
<ExpandableCombobox
|
||||||
|
name="{name}_country"
|
||||||
|
items={options}
|
||||||
|
bind:value={selectedCountryItem}
|
||||||
|
bind:open={countriesOpen}
|
||||||
|
{required}
|
||||||
|
onchange={(e) => {
|
||||||
|
country = countrycodeMap[e.value.value];
|
||||||
|
}}
|
||||||
|
onvalidate={(e) => {
|
||||||
|
countriesValid = e.detail.valid;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<StyledRawInput
|
||||||
|
type="tel"
|
||||||
|
{placeholder}
|
||||||
|
name="{name}_number"
|
||||||
|
bind:value={number}
|
||||||
|
validate={{
|
||||||
|
required: required,
|
||||||
|
func: () => !required || (value !== undefined && value.isValid())
|
||||||
|
}}
|
||||||
|
onvalidate={(e) => {
|
||||||
|
numberValid = e.detail.valid;
|
||||||
|
}}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.ctrlKey || e.key.length > 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/[0-9-()+]/.test(e.key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
oninput={(event: Event) => {
|
||||||
|
const e = event as InputEvent;
|
||||||
|
if (!e.target) return;
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
|
||||||
|
const formatter = new AsYouType(formatterCountryCode);
|
||||||
|
const formatted = formatter.input(input.value);
|
||||||
|
|
||||||
|
if (formatted.length >= input.value.length) input.value = formatted;
|
||||||
|
value = formatter.getNumber();
|
||||||
|
console.log('updated value', value, value?.isValid());
|
||||||
|
|
||||||
|
if (formatter.isValid() && formatter.isInternational() && value) {
|
||||||
|
const country = formatter.getCountry();
|
||||||
|
if (country) {
|
||||||
|
setCountryByISO(country);
|
||||||
|
input.value = value.formatNational();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={['opacity-0 transition-opacity', (!countriesValid || !numberValid) && 'opacity-100']}>
|
||||||
|
<Label for={name} error>
|
||||||
|
{invalidMessage !== undefined && invalidMessage != '' ? invalidMessage : 'Field is required'}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
188
components/PinInput.svelte
Normal file
188
components/PinInput.svelte
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { isValueValid, validate, type ValidatorOptions } from '@repo/validate';
|
||||||
|
import Label from './Label.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
length,
|
||||||
|
required,
|
||||||
|
name,
|
||||||
|
value = $bindable(''),
|
||||||
|
oncomplete,
|
||||||
|
onchange
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
length: number;
|
||||||
|
required?: boolean;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
oncomplete?: (value: string) => void;
|
||||||
|
onchange?: (value: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let hiddenInput: HTMLInputElement;
|
||||||
|
let valid: boolean = $state(true);
|
||||||
|
|
||||||
|
const validateOpts: ValidatorOptions = {
|
||||||
|
required: required,
|
||||||
|
length: length,
|
||||||
|
autovalOnInvalid: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputs: HTMLInputElement[] = $state([]);
|
||||||
|
|
||||||
|
// firstEmptyInputIndex returns the index of the first empty input in the pin input
|
||||||
|
const firstEmptyInputIndex = () => {
|
||||||
|
for (let i = 0; i < inputs.length; i++) {
|
||||||
|
if (inputs[i].value === '') {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inputs.length - 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// joinValue joins the values of the pin input into a single string
|
||||||
|
const joinValue = () => {
|
||||||
|
return inputs.map((input) => input.value).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// updateHiddenInput updates the hidden input value with the current value of the pin input
|
||||||
|
const updateHiddenInput = () => {
|
||||||
|
console.log('updating hidden');
|
||||||
|
hiddenInput.value = value;
|
||||||
|
hiddenInput.dispatchEvent(new KeyboardEvent('keyup'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// onfocusinput selects all the text in an input when it is focused
|
||||||
|
const onfocusinput = (index: number) => {
|
||||||
|
return () => inputs[index].select();
|
||||||
|
};
|
||||||
|
|
||||||
|
// onmousedown triggers focus, forces selection of text and prevents focus on blank inputs
|
||||||
|
const onmousedown = (index: number) => {
|
||||||
|
return (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
let node = inputs[index];
|
||||||
|
if (node.value === '') node = inputs[firstEmptyInputIndex()];
|
||||||
|
|
||||||
|
node.focus();
|
||||||
|
node.select();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// onkeydowninput handles accessibility and editing
|
||||||
|
const onkeydowninput = (index: number) => {
|
||||||
|
return async (e: KeyboardEvent) => {
|
||||||
|
const node = inputs[index];
|
||||||
|
|
||||||
|
// allow pasting into fields
|
||||||
|
if (e.key === 'v' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
const clipboardData = await navigator.clipboard.readText();
|
||||||
|
const clipboardValue = clipboardData.replace(/\D/g, '').slice(0, length);
|
||||||
|
const clipboardValid = await isValueValid(clipboardValue, validateOpts);
|
||||||
|
|
||||||
|
console.log('pasting', clipboardValue, clipboardValid);
|
||||||
|
|
||||||
|
if (clipboardValid) {
|
||||||
|
value = clipboardValue;
|
||||||
|
inputs[inputs.length - 1].focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// passthrough ctrl, cmd, and alt keys
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault(); // prevent default behaviour for all keys
|
||||||
|
|
||||||
|
// allow plain backspace and (forward) delete behaviour under some circumstances
|
||||||
|
if (
|
||||||
|
(e.key === 'Backspace' || e.key === 'Delete') &&
|
||||||
|
node.value !== '' &&
|
||||||
|
(index === inputs.length - 1 || inputs[index + 1].value === '')
|
||||||
|
) {
|
||||||
|
inputs[index].value = '';
|
||||||
|
value = joinValue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Backspace' && node.value === '' && index > 0) {
|
||||||
|
inputs[index - 1].focus();
|
||||||
|
inputs[index - 1].value = '';
|
||||||
|
value = joinValue();
|
||||||
|
} else if ((e.key === 'ArrowLeft' || e.key === 'ArrowDown') && index > 0) {
|
||||||
|
inputs[index - 1].focus();
|
||||||
|
} else if (
|
||||||
|
(e.key === 'ArrowRight' || e.key === 'ArrowUp') &&
|
||||||
|
index < inputs.length - 1 &&
|
||||||
|
node.value !== ''
|
||||||
|
) {
|
||||||
|
inputs[index + 1].focus();
|
||||||
|
} else if (e.key !== 'Backspace' && /^\d$/.test(e.key)) {
|
||||||
|
if (
|
||||||
|
node.selectionStart === null ||
|
||||||
|
node.selectionEnd === null ||
|
||||||
|
(node.value !== '' && Math.abs(node.selectionEnd - node.selectionStart) === 0)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
inputs[index].value = e.key;
|
||||||
|
value = joinValue();
|
||||||
|
|
||||||
|
if (index < inputs.length - 1) {
|
||||||
|
inputs[index + 1].focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (value) updateHiddenInput();
|
||||||
|
if (onchange !== undefined) onchange(value);
|
||||||
|
if (oncomplete !== undefined && value.length === length) oncomplete(value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="hidden"
|
||||||
|
use:validate={validateOpts}
|
||||||
|
onvalidate={(e) => {
|
||||||
|
valid = e.detail.valid;
|
||||||
|
}}
|
||||||
|
bind:this={hiddenInput}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if label}
|
||||||
|
<Label bigError={!valid} for={name}>{label}</Label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
{#each { length: length } as _, i}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class={[
|
||||||
|
'px[1.125rem] w-[5ch] rounded-sm pb-3.5 pt-4 transition-colors',
|
||||||
|
'text-center align-middle font-mono font-normal placeholder:font-normal',
|
||||||
|
'border-accent dark:border-accent/50 border',
|
||||||
|
'text-text placeholder:text-text/30 dark:text-background dark:placeholder:text-background/30',
|
||||||
|
'dark:bg-text-800 bg-white dark:sm:bg-slate-800',
|
||||||
|
!valid && i >= value.length && 'border-red-500!'
|
||||||
|
]}
|
||||||
|
value={value[i] || ''}
|
||||||
|
{required}
|
||||||
|
maxlength="1"
|
||||||
|
onfocus={onfocusinput(i)}
|
||||||
|
onmousedown={onmousedown(i)}
|
||||||
|
onkeydown={onkeydowninput(i)}
|
||||||
|
bind:this={inputs[i]}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
52
components/RadioGroup.svelte
Normal file
52
components/RadioGroup.svelte
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { RadioGroup } from 'melt/builders';
|
||||||
|
import type { RadioGroupProps } from 'melt/builders';
|
||||||
|
import { scale } from 'svelte/transition';
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: RadioGroupProps & {
|
||||||
|
items: string[];
|
||||||
|
label: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const group = new RadioGroup(props);
|
||||||
|
const isVert = $derived(group.orientation === 'vertical');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex w-fit flex-col gap-2" {...group.root}>
|
||||||
|
<label {...group.label} class="font-medium">{label}</label>
|
||||||
|
<div class="flex {isVert ? 'flex-col gap-1' : 'flex-row gap-3'}">
|
||||||
|
{#each items as i}
|
||||||
|
{@const item = group.getItem(i)}
|
||||||
|
<div
|
||||||
|
class="outline-hidden ring-accent-500 focus-visible:ring-3 data-disabled:cursor-not-allowed data-disabled:opacity-50 group -ml-1 flex items-center gap-3
|
||||||
|
rounded p-1"
|
||||||
|
{...item.attrs}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="group-aria-[checked]:border-accent-500 relative size-6
|
||||||
|
rounded-full border-2 border-neutral-400 bg-neutral-100 shadow-sm
|
||||||
|
hover:bg-gray-100 group-aria-[checke]:bg-transparent
|
||||||
|
data-[disabled=true]:bg-gray-400 dark:border-white dark:bg-transparent"
|
||||||
|
>
|
||||||
|
{#if item.checked}
|
||||||
|
<div
|
||||||
|
class="group-aria-[checked]:bg-accent-500 absolute left-1/2 top-1/2 size-3 -translate-x-1/2 -translate-y-1/2
|
||||||
|
rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
transition:scale={{ duration: 75 }}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="cursor-default select-none font-semibold capitalize leading-none">
|
||||||
|
{i}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<input {...group.hiddenInput} />
|
||||||
|
</div>
|
||||||
66
components/Select.svelte
Normal file
66
components/Select.svelte
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Label from './Label.svelte';
|
||||||
|
import { validate } from '@repo/validate';
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
value = $bindable(''),
|
||||||
|
required,
|
||||||
|
missingMessage,
|
||||||
|
node = $bindable(),
|
||||||
|
options,
|
||||||
|
placeholder
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
missingMessage?: string;
|
||||||
|
node?: HTMLSelectElement;
|
||||||
|
options: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let valid: boolean = $state(true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label for={name}>{label}</Label>
|
||||||
|
<select
|
||||||
|
id={name}
|
||||||
|
{name}
|
||||||
|
bind:value
|
||||||
|
class={[
|
||||||
|
'border-accent w-full rounded-sm border bg-white px-[1.125rem] py-3.5 font-normal transition-colors',
|
||||||
|
'text-text placeholder:text-text/60 dark:border-accent/50 dark:bg-text-800 placeholder:font-normal',
|
||||||
|
'dark:text-background dark:placeholder:text-background/60 dark:sm:bg-slate-800',
|
||||||
|
!valid && 'border-red-500!'
|
||||||
|
]}
|
||||||
|
use:validate={{ required }}
|
||||||
|
onvalidate={(e) => {
|
||||||
|
valid = e.detail.valid;
|
||||||
|
}}
|
||||||
|
bind:this={node}
|
||||||
|
>
|
||||||
|
{#if placeholder}
|
||||||
|
<option value="" disabled selected={value === '' || value === undefined}>
|
||||||
|
{placeholder}
|
||||||
|
</option>
|
||||||
|
{/if}
|
||||||
|
{#each options as opt}
|
||||||
|
<option value={opt.value} selected={opt.value === value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}>
|
||||||
|
<Label for={name} error>
|
||||||
|
{missingMessage !== undefined && missingMessage !== '' ? missingMessage : 'Field is required'}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
53
components/Spinner.svelte
Normal file
53
components/Spinner.svelte
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size }: { size: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="lds-ring" style="--size: {size}">
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.lds-ring {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
width: var(--size);
|
||||||
|
height: var(--size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lds-ring div {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
width: calc(var(--size) * 0.8);
|
||||||
|
height: calc(var(--size) * 0.8);
|
||||||
|
margin: max(calc(var(--size) * 0.1), 2px);
|
||||||
|
border: max(calc(var(--size) * 0.1), 2px) solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||||
|
border-color: #fff transparent transparent transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lds-ring div:nth-child(1) {
|
||||||
|
animation-delay: -0.45s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lds-ring div:nth-child(2) {
|
||||||
|
animation-delay: -0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lds-ring div:nth-child(3) {
|
||||||
|
animation-delay: -0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes lds-ring {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
454
components/StateMachine.svelte
Normal file
454
components/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 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>
|
||||||
54
components/StyledRawInput.svelte
Normal file
54
components/StyledRawInput.svelte
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||||
|
import { validate, type InputValidatorEvent, type ValidatorOptions } from '@repo/validate';
|
||||||
|
|
||||||
|
type $Props = Omit<HTMLInputAttributes, 'name' | 'value'> & {
|
||||||
|
name: string;
|
||||||
|
value?: string;
|
||||||
|
validate?: ValidatorOptions;
|
||||||
|
ref?: HTMLInputElement | null;
|
||||||
|
onvalidate?: (e: InputValidatorEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
value = $bindable(''),
|
||||||
|
placeholder,
|
||||||
|
validate: validateOpts,
|
||||||
|
ref = $bindable<HTMLInputElement | null>(null),
|
||||||
|
onvalidate,
|
||||||
|
...others
|
||||||
|
}: $Props = $props();
|
||||||
|
|
||||||
|
let valid: boolean = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// default autovalOnInvalid to true unless explicitly set to false
|
||||||
|
if (validateOpts !== undefined && validateOpts.autovalOnInvalid === undefined) {
|
||||||
|
validateOpts.autovalOnInvalid = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id={name}
|
||||||
|
{name}
|
||||||
|
{placeholder}
|
||||||
|
aria-label={placeholder}
|
||||||
|
{...others}
|
||||||
|
bind:value
|
||||||
|
class={[
|
||||||
|
'border-accent w-full rounded-sm border bg-white px-[1.125rem] py-3.5 font-normal transition-colors',
|
||||||
|
'text-text placeholder:text-text/60 dark:border-accent/50 dark:bg-text-800 placeholder:font-normal',
|
||||||
|
'dark:text-background dark:placeholder:text-background/60 dark:sm:bg-slate-800',
|
||||||
|
'placeholder-shown:text-ellipsis',
|
||||||
|
!valid && 'border-red-500!',
|
||||||
|
others.class
|
||||||
|
]}
|
||||||
|
use:validate={validateOpts}
|
||||||
|
onvalidate={(e) => {
|
||||||
|
valid = e.detail.valid;
|
||||||
|
if (onvalidate) onvalidate(e);
|
||||||
|
}}
|
||||||
|
bind:this={ref}
|
||||||
|
/>
|
||||||
48
components/TextInput.svelte
Normal file
48
components/TextInput.svelte
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Label from './Label.svelte';
|
||||||
|
import StyledRawInput from './StyledRawInput.svelte';
|
||||||
|
import { type ValidatorOptions } from '@repo/validate';
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
value = $bindable(''),
|
||||||
|
placeholder,
|
||||||
|
type,
|
||||||
|
validate: validateOpts,
|
||||||
|
invalidMessage
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
type?: HTMLInputElement['type'];
|
||||||
|
validate?: ValidatorOptions;
|
||||||
|
invalidMessage?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let valid: boolean = $state(true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#if label}
|
||||||
|
<Label for={name}>{label}</Label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<StyledRawInput
|
||||||
|
{placeholder}
|
||||||
|
{name}
|
||||||
|
{type}
|
||||||
|
bind:value
|
||||||
|
validate={validateOpts}
|
||||||
|
onvalidate={(e) => {
|
||||||
|
valid = e.detail.valid;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}>
|
||||||
|
<Label for={name} error>
|
||||||
|
{invalidMessage !== undefined && invalidMessage != '' ? invalidMessage : 'Field is required'}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
104
components/TimezoneInput.svelte
Normal file
104
components/TimezoneInput.svelte
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
const getTimeZonePart = (
|
||||||
|
timeZone: string,
|
||||||
|
timeZoneName: Intl.DateTimeFormatOptions['timeZoneName']
|
||||||
|
) => {
|
||||||
|
return new Intl.DateTimeFormat('en', {
|
||||||
|
timeZone,
|
||||||
|
timeZoneName
|
||||||
|
})
|
||||||
|
.formatToParts()
|
||||||
|
.find((part) => part.type === 'timeZoneName')?.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// wbr takes a string and adds a <wbr> tag after each forward slash
|
||||||
|
const wbr = (str: string): string => {
|
||||||
|
return str.replace(/\//g, '/<wbr />');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import ExpandableCombobox, { type ComboboxItem } from './Combobox.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
value = $bindable(''),
|
||||||
|
invalidMessage,
|
||||||
|
required
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
name: string;
|
||||||
|
value?: string;
|
||||||
|
invalidMessage?: string;
|
||||||
|
required?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const sortedTimeZones = Intl.supportedValuesOf('timeZone')
|
||||||
|
.map((timeZone) => {
|
||||||
|
// get short offset (e.g. GMT+1) for the timezone
|
||||||
|
const offset = getTimeZonePart(timeZone, 'shortOffset');
|
||||||
|
// if (!offset) return null;
|
||||||
|
|
||||||
|
// get long representation (e.g. Pacific Standard Time) for the timezone
|
||||||
|
const long = getTimeZonePart(timeZone, 'long');
|
||||||
|
|
||||||
|
// get short representation (e.g. PST) for the timezone
|
||||||
|
const short = getTimeZonePart(timeZone, 'short');
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeZone,
|
||||||
|
offset,
|
||||||
|
long,
|
||||||
|
short
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((timeZone) => timeZone !== null)
|
||||||
|
.sort((a, b) => {
|
||||||
|
return a.timeZone.localeCompare(b.timeZone);
|
||||||
|
});
|
||||||
|
|
||||||
|
const options: ComboboxItem[] = sortedTimeZones.map((timeZone) => {
|
||||||
|
const infotext = [...new Set([timeZone.short, timeZone.offset])]
|
||||||
|
.filter((item) => item !== undefined)
|
||||||
|
.join(' · ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: timeZone.timeZone,
|
||||||
|
label: `${timeZone.timeZone.replaceAll('_', ' ')}`,
|
||||||
|
preview: timeZone.long,
|
||||||
|
infotext: infotext,
|
||||||
|
render: timezoneLabel
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const optionsMap = options.reduce(
|
||||||
|
(acc, option) => {
|
||||||
|
acc[option.value] = option;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, ComboboxItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
let timezone = $state<ComboboxItem | undefined>(
|
||||||
|
optionsMap[Intl.DateTimeFormat().resolvedOptions().timeZone]
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
console.log(`timezone="${timezone?.value}"`);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ExpandableCombobox
|
||||||
|
{label}
|
||||||
|
{name}
|
||||||
|
{invalidMessage}
|
||||||
|
{required}
|
||||||
|
bind:value={timezone}
|
||||||
|
items={options}
|
||||||
|
matchWidth
|
||||||
|
placeholder="Select a timezone"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#snippet timezoneLabel(item: ComboboxItem)}
|
||||||
|
{@html wbr(item.label ?? 'Missing label')}
|
||||||
|
{/snippet}
|
||||||
71
components/ToggleGroup.svelte
Normal file
71
components/ToggleGroup.svelte
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Label from './Label.svelte';
|
||||||
|
import ToggleSelect from './ToggleSelect.svelte';
|
||||||
|
import { validate, validateInput } from '@repo/validate';
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
selected = $bindable([]),
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
missingMessage
|
||||||
|
}: {
|
||||||
|
items: string[];
|
||||||
|
selected?: string[];
|
||||||
|
name?: string;
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
missingMessage?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let inputElement: HTMLInputElement | undefined = $state<HTMLInputElement>();
|
||||||
|
let valid: boolean = $state(true);
|
||||||
|
|
||||||
|
const makeSelectedHandler = (name: string) => {
|
||||||
|
return (toggled: boolean) => {
|
||||||
|
if (!inputElement) return;
|
||||||
|
if (toggled) selected = [...selected, name];
|
||||||
|
else selected = selected.filter((item) => item !== name);
|
||||||
|
inputElement.value = JSON.stringify(selected);
|
||||||
|
validateInput(inputElement); // trigger validation on hidden input
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#if label && name}
|
||||||
|
<Label for={name}>{label}</Label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
{#if name}
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
{name}
|
||||||
|
required={required ? true : false}
|
||||||
|
use:validate={{ required, baseval: '[]' }}
|
||||||
|
bind:this={inputElement}
|
||||||
|
onvalidate={(e) => {
|
||||||
|
valid = e.detail.valid;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each items as item}
|
||||||
|
<ToggleSelect name={name ? undefined : `toggl_${item}`} ontoggle={makeSelectedHandler(item)}>
|
||||||
|
<span class="capitalize">{item}</span>
|
||||||
|
</ToggleSelect>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if name}
|
||||||
|
<div class={['mt-2 opacity-0 transition-opacity', !valid && 'opacity-100']}>
|
||||||
|
<Label for={name} error>
|
||||||
|
{missingMessage !== undefined && missingMessage !== ''
|
||||||
|
? missingMessage
|
||||||
|
: 'Field is required'}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
37
components/ToggleSelect.svelte
Normal file
37
components/ToggleSelect.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { MouseEventHandler } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
name,
|
||||||
|
selected = false,
|
||||||
|
children,
|
||||||
|
onclick,
|
||||||
|
ontoggle
|
||||||
|
}: {
|
||||||
|
name?: string;
|
||||||
|
selected?: boolean;
|
||||||
|
children: Snippet;
|
||||||
|
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
ontoggle?: (selected: boolean) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const handleToggleSelectClick: MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||||
|
selected = !selected; // update state
|
||||||
|
ontoggle?.(selected); // send event to parent
|
||||||
|
onclick?.(event); // pass onclick handler through
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if name}
|
||||||
|
<input type="hidden" value={selected} {name} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="rounded-3xl border px-6 py-2.5 font-medium transition-colors {selected
|
||||||
|
? 'border-secondary bg-primary text-background hover:bg-primary-600'
|
||||||
|
: 'border-accent text-text dark:border-accent/50 dark:bg-text dark:text-background dark:hover:bg-text-900 bg-white hover:bg-slate-100'}"
|
||||||
|
onclick={handleToggleSelectClick}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</button>
|
||||||
3
eslint.config.js
Normal file
3
eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { config } from '@repo/eslint-config/index.js';
|
||||||
|
|
||||||
|
export default config;
|
||||||
17
index.ts
Normal file
17
index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export { default as Button } from './components/Button.svelte';
|
||||||
|
export { default as CenterBox } from './components/CenterBox.svelte';
|
||||||
|
export { default as Combobox } from './components/Combobox.svelte';
|
||||||
|
export { default as FramelessButton } from './components/FramelessButton.svelte';
|
||||||
|
export { default as Label } from './components/Label.svelte';
|
||||||
|
export { default as Link } from './components/Link.svelte';
|
||||||
|
export { default as PhoneInput } from './components/PhoneInput.svelte';
|
||||||
|
export { default as PinInput } from './components/PinInput.svelte';
|
||||||
|
export { default as RadioGroup } from './components/RadioGroup.svelte';
|
||||||
|
export { default as Select } from './components/Select.svelte';
|
||||||
|
export { default as Spinner } from './components/Spinner.svelte';
|
||||||
|
export { default as StateMachine, type StateMachinePage } from './components/StateMachine.svelte';
|
||||||
|
export { default as StyledRawInput } from './components/StyledRawInput.svelte';
|
||||||
|
export { default as TextInput } from './components/TextInput.svelte';
|
||||||
|
export { default as TimezoneInput } from './components/TimezoneInput.svelte';
|
||||||
|
export { default as ToggleGroup } from './components/ToggleGroup.svelte';
|
||||||
|
export { default as ToggleSelect } from './components/ToggleSelect.svelte';
|
||||||
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "@repo/ui",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"module": "index.ts",
|
||||||
|
"main": "index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./index.ts",
|
||||||
|
"svelte": "./index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@repo/eslint-config": "workspace:*",
|
||||||
|
"@repo/tailwindcss-config": "workspace:*",
|
||||||
|
"@repo/typescript-config": "workspace:*",
|
||||||
|
"eslint": "^9.24.0",
|
||||||
|
"svelte": "^5.25.3",
|
||||||
|
"@sveltejs/kit": "^2.20.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@repo/validate": "workspace:*",
|
||||||
|
"@jsrob/svelte-portal": "^0.2.1",
|
||||||
|
"country-state-city": "^3.2.1",
|
||||||
|
"libphonenumber-js": "^1.12.6",
|
||||||
|
"melt": "^0.12.0",
|
||||||
|
"phosphor-svelte": "^3.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
3
tsconfig.json
Normal file
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["@repo/typescript-config/svelte.json"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user