partially refactor components to ui package

This commit is contained in:
Elijah Duffy
2025-04-13 07:56:23 -07:00
commit bf2ef338e9
21 changed files with 2104 additions and 0 deletions

150
components/Button.svelte Normal file
View 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>

View 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
View 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>

View 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
View 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
View 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>

View 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
View 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>

View 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
View 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
View 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>

View 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>

View 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}
/>

View 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>

View 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}

View 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>

View 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
View File

@@ -0,0 +1,3 @@
import { config } from '@repo/eslint-config/index.js';
export default config;

17
index.ts Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
{
"extends": ["@repo/typescript-config/svelte.json"]
}