12 Commits

Author SHA1 Message Date
Elijah Duffy
aa39aaf84f 1.0.3 2026-03-03 16:42:45 -08:00
Elijah Duffy
2d694a7277 dialog: title & control customization with snippets, fixed callbacks 2026-03-03 16:42:38 -08:00
Elijah Duffy
ae7d4912f9 1.0.2 2026-02-13 16:28:35 -08:00
Elijah Duffy
1c66bc0fcf improve error handling with 'builder'-type structure 2026-02-13 16:28:25 -08:00
Elijah Duffy
3b659c1e2d 1.0.1 2026-02-04 14:54:21 -08:00
Elijah Duffy
90ff836061 text input: change default asterisk behaviour
asterisk now shown by default if field is required.
2026-02-04 14:53:14 -08:00
Elijah Duffy
5e5f133763 statemachine: add onsubmit callback 2026-02-04 11:19:03 -08:00
Elijah Duffy
7317d69d9b 1.0.0 2026-01-26 18:02:07 -08:00
Elijah Duffy
e6d99cdfd2 add ScrollBox convenience helper 2026-01-26 18:01:23 -08:00
Elijah Duffy
2ae35cf847 tabs: supported padded mode with spacing-layout 2026-01-26 18:01:08 -08:00
Elijah Duffy
96daed474b add --spacing-layout helper 2026-01-26 18:00:57 -08:00
Elijah Duffy
eabb0f2dda unlink validate, enable dependency build scripts 2026-01-26 18:00:32 -08:00
13 changed files with 335 additions and 114 deletions

View File

@@ -4,7 +4,7 @@
"type": "git",
"url": "https://gitea.auvem.com/svelte-toolkit/sui.git"
},
"version": "0.3.5",
"version": "1.0.3",
"scripts": {
"dev": "vite dev",
"build": "vite build && pnpm run prepack",

22
pnpm-lock.yaml generated
View File

@@ -4,9 +4,6 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
'@svelte-toolkit/validate': link:../validate
importers:
.:
@@ -18,8 +15,8 @@ importers:
specifier: ^0.2.1
version: 0.2.1(svelte@5.38.1)
'@svelte-toolkit/validate':
specifier: link:../validate
version: link:../validate
specifier: ^1.0.1
version: 1.0.1(svelte@5.38.1)
'@sveltejs/kit':
specifier: ^2.20.2
version: 2.28.0(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.38.1)(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1)))(svelte@5.38.1)(vite@6.3.5(@types/node@24.2.1)(jiti@2.5.1)(lightningcss@1.30.1))
@@ -517,6 +514,11 @@ packages:
'@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
'@svelte-toolkit/validate@1.0.1':
resolution: {integrity: sha512-KqyWc9m0nQwG7gay+hbHqwBFcUsb8q5+v7/wEwul7YedSEOwofK3XTLm5m9TSMm3djfhNSBJ+XER8E9YE8TDXA==, tarball: https://gitea.auvem.com/api/packages/svelte-toolkit/npm/%40svelte-toolkit%2Fvalidate/-/1.0.1/validate-1.0.1.tgz}
peerDependencies:
svelte: ^5.0.0
'@sveltejs/acorn-typescript@1.0.5':
resolution: {integrity: sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==}
peerDependencies:
@@ -667,6 +669,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/google.maps@3.58.1':
resolution: {integrity: sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -1937,6 +1942,11 @@ snapshots:
'@standard-schema/spec@1.0.0': {}
'@svelte-toolkit/validate@1.0.1(svelte@5.38.1)':
dependencies:
'@types/google.maps': 3.58.1
svelte: 5.38.1
'@sveltejs/acorn-typescript@1.0.5(acorn@8.15.0)':
dependencies:
acorn: 8.15.0
@@ -2086,6 +2096,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/google.maps@3.58.1': {}
'@types/json-schema@7.0.15': {}
'@types/node@24.2.1':

View File

@@ -1,2 +1,3 @@
overrides:
'@svelte-toolkit/validate': link:../validate
onlyBuiltDependencies:
- '@tailwindcss/oxide'
- esbuild

View File

@@ -42,8 +42,11 @@
}
type DialogControlButton = {
/** Label for the button */
label?: string;
/** Additional classes to apply to the button */
class?: ClassValue;
/** Callback when the button is pressed */
action?: (dialog: DialogAPI) => void;
};
@@ -51,25 +54,36 @@
* Configures the default dialog controls.
*/
export type DialogControls = {
/** Options for the bottom cancel button */
cancel?: DialogControlButton | null;
/** Options for the bottom submit button */
ok?: DialogControlButton | null;
close?: Omit<DialogControlButton, 'label'> | null;
/** Inverts the order of the buttons */
flip?: boolean;
};
/**
* Stores internal state of the dialog, everything necessary to render
* internal snippets.
*/
type DialogState = {
frozen: boolean;
loading: boolean;
disabled: boolean;
api: DialogAPI;
};
const defaultDialogControls: DialogControls = {
cancel: {
label: 'Cancel'
},
ok: {
label: 'OK'
},
close: {}
cancel: { label: 'Cancel' },
ok: { label: 'OK' }
};
export { dialogCancelButton, dialogOkButton, dialogCloseButton };
</script>
<script lang="ts">
import { Portal } from '@jsrob/svelte-portal';
import { type Snippet } from 'svelte';
import { untrack, type Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { fade } from 'svelte/transition';
import { flyAndScale } from './transition';
@@ -80,17 +94,33 @@
import { mergeOverrideObject } from './util';
interface Props {
/** Bindable open state of the dialog */
open?: boolean;
title: string;
description?: string;
/** Title of the dialog */
title: string | Snippet;
/** Description of the dialog, optionally rendered below the title */
description?: string | Snippet;
/** Size of the dialog (default: 'sm') */
size?: 'sm' | 'md' | 'lg' | 'max';
/** Additional classes for the dialog */
class?: ClassValue;
/** Content of the dialog */
children?: Snippet;
/** Bottom controls for the dialog */
controls?: Snippet | DialogControls;
/** Sets bottom alignment of controls (default: end) */
controlsAlign?: 'start' | 'center' | 'end';
/** Top-right close control */
close?: Snippet | Omit<DialogControlButton, 'label'> | null;
/** Callback when the dialog is opened */
onopen?: (dialog: DialogAPI) => void;
/** Callback when the dialog is closed */
onclose?: (dialog: DialogAPI) => void;
/** If default controls are used, controls loading state of submit button */
loading?: boolean;
/** If default controls are used, freezes all interactions */
frozen?: boolean;
/** If default controls are used, disables submit button */
disabled?: boolean;
}
@@ -102,6 +132,8 @@
class: classValue,
children,
controls: rawControls = defaultDialogControls,
controlsAlign = 'end',
close = {},
onopen,
onclose,
loading = $bindable(false),
@@ -122,14 +154,15 @@
$effect(() => {
if (open) {
document.body.style.overflow = 'hidden';
onopen?.(dialogAPI);
untrack(() => onopen?.(dialogAPI));
} else {
document.body.style.overflow = '';
onclose?.(dialogAPI);
untrack(() => onclose?.(dialogAPI));
}
});
const dialogAPI: DialogAPI = {
/** DialogAPI instance to control this dialog */
export const dialogAPI: DialogAPI = {
error: (message) => (error = ErrorMessage.from(message)),
close: () => (open = false),
open: () => (open = true),
@@ -146,6 +179,16 @@
canContinue: () => !loading && !disabled && !frozen,
title: (newTitle) => (title = newTitle)
};
/** Returns the current state of the dialog */
export const getState = (): DialogState => {
return {
frozen,
loading,
disabled,
api: dialogAPI
};
};
</script>
<Portal target="body">
@@ -190,7 +233,21 @@
start: 0.96
}}
>
<h2 class="pointer-events-none mb-2 text-lg font-medium text-black select-none">{title}</h2>
<div class="flex items-center justify-between">
<!-- Dialog title -->
<h2 class="pointer-events-none mb-2 text-lg font-medium text-black select-none">
{@render stringOrSnippet(title)}
</h2>
<!-- Close Button -->
{#if close !== null}
{#if typeof close === 'function'}
{@render close()}
{:else}
{@render dialogCloseButton(getState(), close)}
{/if}
{/if}
</div>
{#if error}
<ErrorBox {error} />
@@ -198,68 +255,102 @@
{#if description}
<p class="mb-3 leading-normal text-zinc-600">
{description}
{@render stringOrSnippet(description)}
</p>
{/if}
{#if children}{@render children()}{:else}Dialog is empty{/if}
<!-- Dialog Controls -->
<div class="mt-6 flex justify-end gap-4">
{#if controls && typeof controls === 'function'}{@render controls()}{:else}
{#if controls.cancel !== null}
<Button
class={controls?.cancel?.class}
onclick={() => {
if (controls?.cancel?.action) {
controls.cancel.action(dialogAPI);
} else if (!frozen) {
open = false;
}
}}
disabled={frozen}
<div
class={[
'mt-6 flex gap-4',
controlsAlign === 'start' && 'justify-start',
controlsAlign === 'center' && 'justify-center',
controlsAlign === 'end' && 'justify-end'
]}
>
{controls?.cancel?.label || 'Cancel'}
</Button>
{#if controls && typeof controls === 'function'}{@render controls()}{:else}
{#if controls.ok !== null}
{@render dialogOkButton(getState(), controls.ok)}
{/if}
{#if controls?.flip}
{#if controls.cancel !== null}
{@render dialogCancelButton(getState(), controls.cancel)}
{/if}
{:else}
{#if controls.cancel !== null}
{@render dialogCancelButton(getState(), controls.cancel)}
{/if}
{#if controls.ok !== null}
<Button
class={controls?.ok?.class}
onclick={() => {
if (controls?.ok?.action) {
controls.ok.action(dialogAPI);
} else if (!frozen && !loading && !disabled) {
open = false;
}
}}
disabled={frozen || loading || disabled}
{loading}
>
{controls?.ok?.label || 'OK'}
</Button>
{@render dialogOkButton(getState(), controls.ok)}
{/if}
{/if}
{/if}
</div>
<!-- Close Button -->
{#if typeof controls === 'function' || controls?.close !== null}
<button
type="button"
aria-label="close"
class="absolute top-4 right-4 inline-flex cursor-pointer items-center
justify-center disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => {
if (typeof controls !== 'function' && controls?.close?.action) {
controls?.close?.action?.(dialogAPI);
} else if (!frozen) {
open = false;
}
}}
disabled={frozen}
>
<X size="1.5em" weight="bold" />
</button>
{/if}
</div>
</div>
{/snippet}
{#snippet dialogCancelButton(state: DialogState, opts?: DialogControls['cancel'])}
<Button
class={opts?.class}
onclick={() => {
if (opts?.action) {
opts.action(state.api);
} else if (!state.frozen) {
state.api.close();
}
}}
disabled={state.frozen}
>
{opts?.label || 'Cancel'}
</Button>
{/snippet}
{#snippet dialogOkButton(state: DialogState, opts?: DialogControls['ok'])}
<Button
class={opts?.class}
onclick={() => {
if (opts?.action) {
opts.action(state.api);
} else if (!state.frozen && !state.loading && !state.disabled) {
state.api.close();
}
}}
disabled={state.frozen || state.loading || state.disabled}
loading={state.loading}
>
{opts?.label || 'OK'}
</Button>
{/snippet}
{#snippet dialogCloseButton(state: DialogState, opts?: Omit<DialogControlButton, 'label'> | null)}
<button
type="button"
aria-label="close"
class={[
'inline-flex cursor-pointer items-center justify-center',
'disabled:cursor-not-allowed disabled:opacity-50',
'rounded-full p-2 transition-colors hover:bg-zinc-200/50'
]}
onclick={() => {
if (opts?.action) {
opts.action(state.api);
} else if (!state.frozen) {
state.api.close();
}
}}
disabled={state.frozen}
>
<X size="1.25em" weight="bold" />
</button>
{/snippet}
{#snippet stringOrSnippet(val: string | Snippet)}
{#if typeof val === 'string'}
{val}
{:else}
{@render val()}
{/if}
{/snippet}

View File

@@ -1,18 +1,28 @@
<script lang="ts">
import type { ClassValue } from 'svelte/elements';
import type { ErrorMessage } from './error';
import { ErrorMessage, type RawError } from './error';
interface Props {
error: ErrorMessage | null;
/** Error in the form of an ErrorMessage */
error?: ErrorMessage | null;
/** Raw error that can be converted to an ErrorMessage */
rawError?: RawError | null;
/** Additional CSS classes for the error box */
class?: ClassValue | null;
}
let { error, class: classValue }: Props = $props();
let { error, rawError, class: classValue }: Props = $props();
let errorMessage = $derived.by(() => {
if (error) return error;
if (rawError) return new ErrorMessage(rawError);
});
</script>
{#if error}
<!-- eslint-disable svelte/no-at-html-tags -->
{#if errorMessage && errorMessage.hasError()}
<div class={['bg-sui-accent text-sui-background my-4 rounded-xs px-6 py-4', classValue]}>
{@html error.message}
{#each errorMessage.lines as line}
<p>{line}</p>
{/each}
</div>
{/if}

28
src/lib/ScrollBox.svelte Normal file
View File

@@ -0,0 +1,28 @@
<!-- @component
ScrollBox provides a small convenience wrapper for creating a scrollable container.
This component expects to be used as a direct child of a flex container that typically
has a constrained height. It applies the necessary CSS styles to ensure that its content
can scroll properly and that the container expands to the maximum available space within
the parent flexbox. Typically, a parent might have these Tailwind classes applied:
`flex h-full`
More complex layouts that should not expand to fill all available space should use
a custom container with `overflow-auto` and an intentionally constrained height.
-->
<script lang="ts">
import type { ClassValue } from 'svelte/elements';
interface Props {
/** Applies ui-layout-px padding to the scrollable content area (default: false) */
padded?: boolean;
class?: ClassValue;
children?: import('svelte').Snippet;
}
let { padded = false, class: classValue, children }: Props = $props();
</script>
<div class={['min-h-0 min-w-0 flex-1 overflow-auto', padded && 'p-layout', classValue]}>
{@render children?.()}
</div>

View File

@@ -40,7 +40,7 @@
import { tweened } from 'svelte/motion';
import { fade, fly } from 'svelte/transition';
import { validateForm } from '@svelte-toolkit/validate';
import { ArrowLeft, Check, CheckFat } from 'phosphor-svelte';
import { ArrowLeft, CheckFat } from 'phosphor-svelte';
import type { IconDef } from './util';
interface Props {
@@ -49,6 +49,13 @@
failure: StateMachinePage;
index?: number;
action?: string;
/**
* Called when the form is submitted at the end of the state machine.
* Receives the collated form data as a key-value object.
* If you wish to prevent the submission, return false.
* If you wish to indicate a failure, you can also throw an error.
*/
onsubmit?: (data: Record<string, string>) => Promise<boolean> | boolean;
}
let {
@@ -57,7 +64,8 @@
success,
failure,
index = $bindable(0),
action
action,
onsubmit
}: Props = $props();
// add success and failure pages to the end of the pages array
@@ -250,6 +258,22 @@
// update button state
buttonLoading = true;
if (onsubmit) {
try {
const res = await onsubmit(collatedFormData);
if (res === false) {
buttonLoading = false;
index = pages.length - 2;
return;
}
} catch (e) {
console.log('onsubmit handler failed, submission prevented', e);
buttonLoading = false;
index = pages.length - 1;
return;
}
}
const form_data = new FormData();
for (const [key, value] of Object.entries(collatedFormData)) {
form_data.append(key, value);

View File

@@ -1,3 +1,8 @@
<!--
Documents the purpose, API, and usage details of the selected component.
Include key props, expected behavior, and any important notes for consumers.
-->
<script lang="ts" module>
export type TabPage = {
title: string;
@@ -14,12 +19,16 @@
interface Props {
pages: TabPage[];
/** Currently active tab index (default: 0) */
activeIndex?: number;
/** Callback fired when the active tab changes */
onchange?: (event: { index: number; tab: TabPage }) => void;
/** Applies layout padding to the tab header & content areas (default: false) */
padded?: boolean;
class?: ClassValue | null;
}
let { pages, activeIndex = 0, onchange, class: classValue }: Props = $props();
let { pages, activeIndex = 0, onchange, padded = false, class: classValue }: Props = $props();
let primaryContainerEl: HTMLDivElement;
let tabContainerEl: HTMLDivElement;
@@ -88,7 +97,10 @@
<div bind:this={primaryContainerEl} class={[classValue]}>
<div
bind:this={tabContainerEl}
class={['border-sui-text/15 relative mb-4 flex items-center gap-5 border-b-2']}
class={[
'border-sui-text/15 relative mb-4 flex items-center gap-5 border-b-2',
padded && 'px-layout'
]}
>
{#each pages as page, i (page.title)}
{@const active = activeIndex === i}
@@ -122,7 +134,7 @@
{#key activeIndex}
<div
class={[]}
class={[padded && 'px-layout']}
in:flyX={{ direction: activeIndex > prevIndex ? 1 : -1, duration: 180, delay: 181 }}
out:flyX={{ direction: activeIndex > prevIndex ? -1 : 1, duration: 180 }}
onoutrostart={lockHeight}

View File

@@ -11,7 +11,7 @@
value?: string;
invalidMessage?: string | null;
ref?: HTMLInputElement | null;
asterisk?: boolean;
asterisk?: boolean | null;
class?: ClassValue | null | undefined;
}
@@ -21,12 +21,16 @@
value = $bindable(''),
invalidMessage = 'Field is required',
ref = $bindable<HTMLInputElement | null>(null),
asterisk = false,
asterisk = null,
class: classValue,
forceInvalid = false,
...others
}: Props = $props();
let valid: boolean = $state(true);
let displayAsterisk = $derived(
asterisk === true || (asterisk !== false && others.validate && others.validate.required)
);
export const focus = () => {
if (ref) ref.focus();
@@ -37,7 +41,7 @@
{#if label}
<Label for={id}>
{label}
{#if asterisk}
{#if displayAsterisk}
<span class="text-red-500">*</span>
{/if}
</Label>
@@ -50,11 +54,12 @@
onvalidate={(e) => {
valid = e.detail.valid;
}}
{forceInvalid}
{...others}
/>
{#if others.validate && invalidMessage !== null}
<div class={['opacity-0 transition-opacity', !valid && 'opacity-100']}>
<div class={['opacity-0 transition-opacity', (!valid || forceInvalid) && 'opacity-100']}>
<Label for={id} error>
{invalidMessage}
</Label>

View File

@@ -10,20 +10,41 @@ export interface GraphError {
export type RawError = Error | string | GraphError[];
export class ErrorMessage {
private _message: string;
private _lines: string[] = [];
/** converts a RawError to a string and stores it for later access */
constructor(raw: RawError) {
this._message = ErrorMessage.rawErrorToString(raw);
/**
* Converts a RawError to an array of lines and stores it for later access,
* or initializes without any errors if the input is null or undefined.
* @param raw The raw error to convert and store, or null/undefined for no error.
* @throws If the raw error is of an unsupported type.
*/
constructor(raw: RawError | null | undefined) {
if (raw) {
this._lines = ErrorMessage.rawErrorToLines(raw);
}
}
/** returns the stored message */
get message(): string {
return this._message;
/** returns the stored lines */
get lines(): string[] {
return this._lines;
}
/** returns the error as a string */
/** returns the error lines as a string, separated by newlines */
toString(): string {
return this._message;
return this._lines.join('\n');
}
/** returns the error lines as an HTML string, separated by <br /> */
toHTML(): string {
return this._lines.join('<br />');
}
/** returns true if there are any error lines */
hasError(): boolean {
return this._lines.length > 0;
}
/** adds a new line to the error message */
addLine(line: string): void {
this._lines.push(line);
}
/** optionally returns a new ErrorMessage only if the RawError is not empty */
@@ -32,28 +53,27 @@ export class ErrorMessage {
return new ErrorMessage(raw);
}
/** converts a RawError to a string */
static rawErrorToString(raw: RawError | null | undefined): string {
if (!raw) return 'No error';
/** converts a RawError to an array of lines */
static rawErrorToLines(raw: RawError | null | undefined): string[] {
if (!raw) return ['No error'];
let errorString: string;
let errorLines: string[];
if (typeof raw === 'string') {
errorString = raw;
errorLines = [raw];
} else if (raw instanceof Error) {
errorString = raw.message;
errorLines = [raw.message];
} else if (Array.isArray(raw)) {
errorString = raw
.flatMap((e) => {
errorLines = raw.map((e) => {
const messageString = e.message || 'Unknown error';
if (e.path && e.path.length > 0) {
return `"${messageString}" at ${e.path.join('.')}`;
}
})
.join('<br />');
return messageString;
});
} else {
throw `Bad error value ${raw}`;
}
return errorString;
return errorLines;
}
}

View File

@@ -6,7 +6,14 @@ export { default as CenterBox } from './CenterBox.svelte';
export { default as Checkbox, type CheckboxState } from './Checkbox.svelte';
export { default as Combobox, type ComboboxOption } from './Combobox.svelte';
export { default as DateInput } from './DateInput.svelte';
export { default as Dialog, type DialogAPI, type DialogControlOpts } from './Dialog.svelte';
export {
default as Dialog,
type DialogAPI,
type DialogControls,
dialogCancelButton,
dialogCloseButton,
dialogOkButton
} from './Dialog.svelte';
export {
default as DurationInput,
formatDuration,
@@ -23,6 +30,7 @@ export { default as Link, rewriteHref } from './Link.svelte';
export { default as PhoneInput } from './PhoneInput.svelte';
export { default as PinInput } from './PinInput.svelte';
export { default as RadioGroup } from './RadioGroup.svelte';
export { default as ScrollBox } from './ScrollBox.svelte';
export { default as Spinner } from './Spinner.svelte';
export { default as StateMachine, type StateMachinePage } from './StateMachine.svelte';
export { default as StyledRawInput } from './StyledRawInput.svelte';

View File

@@ -17,6 +17,15 @@
monospace
);
/* Layout Controls */
--spacing-layout: calc(var(--spacing) * var(--ui-layout-gap, 4));
--spacing-layout-2x: calc(var(--spacing-layout) * 2);
/** TODO: Refine colors so we can pick more intent-based colors, perhaps on a per-component level???
Perhaps it's best to just wrap those individual components and apply classes there instead of
bloating the base styles with too many color variables?
*/
/* Primary Colors */
--color-sui-primary-50: var(--ui-primary-50, #f0f8fe);
--color-sui-primary-100: var(--ui-primary-100, #ddeefc);

View File

@@ -429,6 +429,7 @@
<p class="title">Tabs</p>
<Tabs
padded={true}
pages={[
{
title: 'Dashboard',