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