25 Commits

Author SHA1 Message Date
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
Elijah Duffy
d3c4962495 0.3.5 2025-12-17 22:17:47 -08:00
Elijah Duffy
3cdda64686 dialog: allow disabling controls from rendering 2025-12-17 22:17:42 -08:00
Elijah Duffy
6630051d67 generic merge object function 2025-12-17 22:17:15 -08:00
Elijah Duffy
bf77a20ff9 0.3.4 2025-12-15 17:14:34 -08:00
Elijah Duffy
42c9bc0bcc banner: fix control config merge behaviour 2025-12-15 17:14:33 -08:00
Elijah Duffy
ed48b404d4 0.3.3 2025-12-15 17:05:39 -08:00
Elijah Duffy
54f7924c4a banner: add control swap 2025-12-15 17:05:20 -08:00
Elijah Duffy
d9cd2b406a banner: fix control placement with optionals disabled 2025-12-15 16:42:44 -08:00
Elijah Duffy
46bd6b935a 0.3.2 2025-12-15 15:40:33 -08:00
Elijah Duffy
9782a31846 add Banner component 2025-12-15 15:24:53 -08:00
Elijah Duffy
63b29e3f6a actually fix date input demo bad bind:value 2025-12-15 15:24:19 -08:00
Elijah Duffy
1c4fac7523 demo page: set title properly in svelte:head 2025-12-15 15:23:55 -08:00
Elijah Duffy
098bf75bd3 frameless button, link: add inverted colour state 2025-12-15 15:23:24 -08:00
Elijah Duffy
a321cbffe9 input group: allow wrapping by default 2025-12-15 15:23:12 -08:00
Elijah Duffy
1cc8cd6913 fix date input demo bad bind:value 2025-12-15 14:40:43 -08:00
18 changed files with 568 additions and 109 deletions

View File

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

22
pnpm-lock.yaml generated
View File

@@ -4,9 +4,6 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides:
'@svelte-toolkit/validate': link:../validate
importers: importers:
.: .:
@@ -18,8 +15,8 @@ importers:
specifier: ^0.2.1 specifier: ^0.2.1
version: 0.2.1(svelte@5.38.1) version: 0.2.1(svelte@5.38.1)
'@svelte-toolkit/validate': '@svelte-toolkit/validate':
specifier: link:../validate specifier: ^1.0.1
version: link:../validate version: 1.0.1(svelte@5.38.1)
'@sveltejs/kit': '@sveltejs/kit':
specifier: ^2.20.2 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)) 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': '@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} 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': '@sveltejs/acorn-typescript@1.0.5':
resolution: {integrity: sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==} resolution: {integrity: sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==}
peerDependencies: peerDependencies:
@@ -667,6 +669,9 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 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': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -1937,6 +1942,11 @@ snapshots:
'@standard-schema/spec@1.0.0': {} '@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)': '@sveltejs/acorn-typescript@1.0.5(acorn@8.15.0)':
dependencies: dependencies:
acorn: 8.15.0 acorn: 8.15.0
@@ -2086,6 +2096,8 @@ snapshots:
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/google.maps@3.58.1': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/node@24.2.1': '@types/node@24.2.1':

View File

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

228
src/lib/Banner.svelte Normal file
View File

@@ -0,0 +1,228 @@
<script lang="ts" module>
export interface BannerAPI {
/** opens the banner */
open: () => void;
/** closes the banner */
close: () => void;
/** returns whether the banner is open */
isOpen: () => boolean;
/** disables banner controls */
disable: () => void;
/** enables banner controls */
enable: () => void;
/** returns whether the banner controls are disabled */
isDisabled: () => boolean;
/** freezes banner state (cannot be dismissed) */
freeze: () => void;
/** unfreezes banner state */
unfreeze: () => void;
/** returns whether the banner is frozen */
isFrozen: () => boolean;
}
type BannerControlButton = {
label?: string;
class?: ClassValue;
/** if framed is false, FramelessButton is used instead of Button */
framed?: boolean;
action?: (banner: BannerAPI) => void;
};
export type BannerControls = {
accept?: BannerControlButton | null;
decline?: BannerControlButton | null;
moreInfo?:
| (Omit<BannerControlButton, 'framed'> & {
type?: 'link' | 'framed' | 'frameless';
href?: string;
})
| null;
dismiss?: Omit<BannerControlButton, 'framed'> | null;
/** if true, accept and decline buttons are swapped with more info */
swap?: boolean | null;
};
const defaultBannerControls: BannerControls = {
accept: {
label: 'Accept',
framed: true,
action: (banner) => banner.close()
},
decline: {
label: 'Decline',
framed: false,
action: (banner) => banner.close()
},
dismiss: {
action: (banner) => banner.close()
},
swap: false
};
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import Link from './Link.svelte';
import Button from './Button.svelte';
import FramelessButton from './FramelessButton.svelte';
import { X } from 'phosphor-svelte';
import { mergeOverrideObject } from './util';
/**
* Banner provides a simple component to display a banner message with
* additional tooling for easy control integration. Controls tools are
* geared toward cookie consent, announcements, and similar use cases.
*/
interface Props {
title?: string;
open?: boolean;
disabled?: boolean;
frozen?: boolean;
position?: 'top' | 'bottom';
controls?: BannerControls | null;
/** if true, frameless buttons and links are inverted (default: true) */
invertFrameless?: boolean;
class?: ClassValue | null;
onaccept?: (banner: BannerAPI) => void;
ondecline?: (banner: BannerAPI) => void;
children?: Snippet;
}
let {
title,
open = $bindable(false),
disabled = $bindable(false),
frozen = $bindable(false),
position,
controls: rawControls = defaultBannerControls,
invertFrameless = true,
class: classValue,
onaccept,
ondecline,
children
}: Props = $props();
const controls = $derived(mergeOverrideObject(defaultBannerControls, rawControls));
const api: BannerAPI = {
open: () => (open = true),
close: () => (open = false),
isOpen: () => open,
disable: () => (disabled = true),
enable: () => (disabled = false),
isDisabled: () => disabled,
freeze: () => (frozen = true),
unfreeze: () => (frozen = false),
isFrozen: () => frozen
};
const handleAccept = () => {
controls.accept?.action?.(api);
onaccept?.(api);
};
const handleDecline = () => {
controls.decline?.action?.(api);
ondecline?.(api);
};
</script>
{#if open}
<div
class={[
'fixed left-0 z-50 w-screen px-8 py-6',
position === 'top' ? 'top-0' : 'bottom-0',
'bg-sui-secondary-800 text-sui-background',
classValue
]}
>
{#if title || controls?.dismiss}
<div class="mb-2 flex items-center justify-between gap-4">
{#if title}
<h3 class="mb-2 text-lg font-semibold">{title}</h3>
{/if}
{#if controls?.dismiss}
<FramelessButton
inverted={invertFrameless}
onclick={() => controls.dismiss?.action?.(api)}
class="ml-auto"
>
{#if controls.dismiss?.label}
{controls.dismiss.label}
{/if}
<X class="mt-0.5" size="1.2rem" weight="bold" />
</FramelessButton>
{/if}
</div>
{/if}
{@render children?.()}
{#if controls !== null}
<div
class={['mt-4 flex flex-wrap justify-between gap-4', controls.swap && 'flex-row-reverse']}
>
<!-- More info button/link -->
{#if controls.moreInfo}
{#if controls.moreInfo.type === 'link' && controls.moreInfo.href}
<Link
href={controls.moreInfo.href}
class={controls.moreInfo.class}
inverted={invertFrameless}
>
{controls.moreInfo.label || 'More Info'}
</Link>
{:else if controls.moreInfo.type === 'framed'}
<Button
class={controls.moreInfo.class}
onclick={() => controls.moreInfo?.action?.(api)}
>
{controls.moreInfo.label || 'More Info'}
</Button>
{:else}
<FramelessButton
class={controls.moreInfo.class}
onclick={() => controls.moreInfo?.action?.(api)}
inverted={invertFrameless}
>
{controls.moreInfo.label || 'More Info'}
</FramelessButton>
{/if}
{/if}
<div
class={['flex justify-end gap-4', controls.swap ? 'mr-auto flex-row-reverse' : 'ml-auto']}
>
<!-- Decline button -->
{@render buttonControl(controls.decline, handleDecline, false)}
<!-- Accept button -->
{@render buttonControl(controls.accept, handleAccept, true)}
</div>
</div>
{/if}
</div>
{/if}
{#snippet buttonControl(
button: BannerControlButton | undefined | null,
handleClick: (banner: BannerAPI) => void,
framedDefault: boolean
)}
{#if button}
{#if button.framed || framedDefault}
<Button class={button.class} onclick={() => handleClick(api)}>
{button.label || 'Button'}
</Button>
{:else}
<FramelessButton
class={button.class}
onclick={() => handleClick(api)}
inverted={invertFrameless}
>
{button.label || 'Button'}
</FramelessButton>
{/if}
{/if}
{/snippet}

View File

@@ -41,24 +41,29 @@
title: (title: string) => void; title: (title: string) => void;
} }
type DialogControlButton = {
label?: string;
class?: ClassValue;
action?: (dialog: DialogAPI) => void;
};
/** /**
* Configures the default dialog controls. * Configures the default dialog controls.
*/ */
export type DialogControlOpts = { export type DialogControls = {
cancel?: { cancel?: DialogControlButton | null;
label?: string; ok?: DialogControlButton | null;
class?: ClassValue; close?: Omit<DialogControlButton, 'label'> | null;
action?: (dialog: DialogAPI) => void;
};
ok?: {
label?: string;
class?: ClassValue;
action?: (dialog: DialogAPI) => void;
};
close?: {
class?: ClassValue;
action?: (dialog: DialogAPI) => void;
}; };
const defaultDialogControls: DialogControls = {
cancel: {
label: 'Cancel'
},
ok: {
label: 'OK'
},
close: {}
}; };
</script> </script>
@@ -72,6 +77,7 @@
import { X } from 'phosphor-svelte'; import { X } from 'phosphor-svelte';
import { ErrorMessage, type RawError } from './error'; import { ErrorMessage, type RawError } from './error';
import ErrorBox from './ErrorBox.svelte'; import ErrorBox from './ErrorBox.svelte';
import { mergeOverrideObject } from './util';
interface Props { interface Props {
open?: boolean; open?: boolean;
@@ -80,7 +86,7 @@
size?: 'sm' | 'md' | 'lg' | 'max'; size?: 'sm' | 'md' | 'lg' | 'max';
class?: ClassValue; class?: ClassValue;
children?: Snippet; children?: Snippet;
controls?: Snippet | DialogControlOpts; controls?: Snippet | DialogControls;
onopen?: (dialog: DialogAPI) => void; onopen?: (dialog: DialogAPI) => void;
onclose?: (dialog: DialogAPI) => void; onclose?: (dialog: DialogAPI) => void;
loading?: boolean; loading?: boolean;
@@ -95,7 +101,7 @@
size = 'sm', size = 'sm',
class: classValue, class: classValue,
children, children,
controls, controls: rawControls = defaultDialogControls,
onopen, onopen,
onclose, onclose,
loading = $bindable(false), loading = $bindable(false),
@@ -103,6 +109,12 @@
disabled = $bindable(false) disabled = $bindable(false)
}: Props = $props(); }: Props = $props();
let controls = $derived(
typeof rawControls === 'function'
? rawControls
: mergeOverrideObject(defaultDialogControls, rawControls)
);
let dialogContainer = $state<HTMLDivElement | null>(null); let dialogContainer = $state<HTMLDivElement | null>(null);
let error = $state<ErrorMessage | null>(null); let error = $state<ErrorMessage | null>(null);
@@ -192,8 +204,10 @@
{#if children}{@render children()}{:else}Dialog is empty{/if} {#if children}{@render children()}{:else}Dialog is empty{/if}
<!-- Dialog Controls -->
<div class="mt-6 flex justify-end gap-4"> <div class="mt-6 flex justify-end gap-4">
{#if controls && typeof controls === 'function'}{@render controls()}{:else} {#if controls && typeof controls === 'function'}{@render controls()}{:else}
{#if controls.cancel !== null}
<Button <Button
class={controls?.cancel?.class} class={controls?.cancel?.class}
onclick={() => { onclick={() => {
@@ -207,6 +221,8 @@
> >
{controls?.cancel?.label || 'Cancel'} {controls?.cancel?.label || 'Cancel'}
</Button> </Button>
{/if}
{#if controls.ok !== null}
<Button <Button
class={controls?.ok?.class} class={controls?.ok?.class}
onclick={() => { onclick={() => {
@@ -222,19 +238,28 @@
{controls?.ok?.label || 'OK'} {controls?.ok?.label || 'OK'}
</Button> </Button>
{/if} {/if}
{/if}
</div> </div>
<!-- Close Button -->
{#if typeof controls === 'function' || controls?.close !== null}
<button <button
type="button" type="button"
aria-label="close" aria-label="close"
class="absolute top-4 right-4 inline-flex cursor-pointer items-center class="absolute top-4 right-4 inline-flex cursor-pointer items-center
justify-center disabled:cursor-not-allowed disabled:opacity-50" justify-center disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => { onclick={() => {
if (!frozen) open = false; if (typeof controls !== 'function' && controls?.close?.action) {
controls?.close?.action?.(dialogAPI);
} else if (!frozen) {
open = false;
}
}} }}
disabled={frozen} disabled={frozen}
> >
<X size="1.5em" weight="bold" /> <X size="1.5em" weight="bold" />
</button> </button>
{/if}
</div> </div>
</div> </div>
{/snippet} {/snippet}

View File

@@ -1,18 +1,28 @@
<script lang="ts"> <script lang="ts">
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import type { ErrorMessage } from './error'; import { ErrorMessage, type RawError } from './error';
interface Props { 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; 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> </script>
{#if error} {#if errorMessage && errorMessage.hasError()}
<!-- eslint-disable svelte/no-at-html-tags -->
<div class={['bg-sui-accent text-sui-background my-4 rounded-xs px-6 py-4', classValue]}> <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> </div>
{/if} {/if}

View File

@@ -7,6 +7,7 @@
icon?: IconDef; icon?: IconDef;
iconPosition?: 'left' | 'right'; iconPosition?: 'left' | 'right';
disabled?: boolean; disabled?: boolean;
inverted?: boolean;
class?: ClassValue | null | undefined; class?: ClassValue | null | undefined;
children: Snippet; children: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>; onclick?: MouseEventHandler<HTMLButtonElement>;
@@ -16,6 +17,7 @@
icon, icon,
iconPosition = 'right', iconPosition = 'right',
disabled = false, disabled = false,
inverted = false,
class: classValue, class: classValue,
children, children,
onclick onclick
@@ -31,8 +33,11 @@
<button <button
type="button" type="button"
class={[ class={[
'text-sui-accent hover:text-sui-primary inline-flex cursor-pointer items-center gap-1.5 transition-colors', 'inline-flex cursor-pointer items-center gap-1.5 transition-colors',
disabled && 'pointer-events-none cursor-not-allowed opacity-50', disabled && 'pointer-events-none cursor-not-allowed opacity-50',
inverted
? 'text-sui-background hover:text-sui-background/80 font-medium'
: 'text-sui-accent hover:text-sui-primary',
classValue classValue
]} ]}
{onclick} {onclick}

View File

@@ -9,6 +9,6 @@
let { class: classList, children }: Props = $props(); let { class: classList, children }: Props = $props();
</script> </script>
<div class={['mt-4 flex min-w-80 items-center justify-start gap-2', classList]}> <div class={['mt-4 flex min-w-80 flex-wrap items-center justify-start gap-2', classList]}>
{@render children?.()} {@render children?.()}
</div> </div>

View File

@@ -40,6 +40,7 @@
basepath?: string | null; basepath?: string | null;
disabled?: boolean; disabled?: boolean;
tab?: 'current' | 'new'; tab?: 'current' | 'new';
inverted?: boolean;
class?: ClassValue | null | undefined; class?: ClassValue | null | undefined;
children: Snippet; children: Snippet;
onclick?: MouseEventHandler<HTMLAnchorElement>; onclick?: MouseEventHandler<HTMLAnchorElement>;
@@ -50,6 +51,7 @@
basepath, basepath,
disabled = false, disabled = false,
tab = 'current', tab = 'current',
inverted = false,
class: classValue, class: classValue,
children, children,
onclick onclick
@@ -60,8 +62,11 @@
<a <a
class={[ class={[
'text-sui-accent hover:text-sui-primary inline-flex items-center gap-1.5 transition-colors', 'inline-flex items-center gap-1.5 transition-colors',
disabled && 'pointer-events-none cursor-not-allowed opacity-50', disabled && 'pointer-events-none cursor-not-allowed opacity-50',
inverted
? 'text-sui-background hover:text-sui-background/80 font-medium'
: 'text-sui-accent hover:text-sui-primary',
classValue classValue
]} ]}
href={computedHref} href={computedHref}

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 { tweened } from 'svelte/motion';
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
import { validateForm } from '@svelte-toolkit/validate'; import { validateForm } from '@svelte-toolkit/validate';
import { ArrowLeft, Check, CheckFat } from 'phosphor-svelte'; import { ArrowLeft, CheckFat } from 'phosphor-svelte';
import type { IconDef } from './util'; import type { IconDef } from './util';
interface Props { interface Props {
@@ -49,6 +49,13 @@
failure: StateMachinePage; failure: StateMachinePage;
index?: number; index?: number;
action?: string; 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 { let {
@@ -57,7 +64,8 @@
success, success,
failure, failure,
index = $bindable(0), index = $bindable(0),
action action,
onsubmit
}: Props = $props(); }: Props = $props();
// add success and failure pages to the end of the pages array // add success and failure pages to the end of the pages array
@@ -250,6 +258,22 @@
// update button state // update button state
buttonLoading = true; 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(); const form_data = new FormData();
for (const [key, value] of Object.entries(collatedFormData)) { for (const [key, value] of Object.entries(collatedFormData)) {
form_data.append(key, value); 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> <script lang="ts" module>
export type TabPage = { export type TabPage = {
title: string; title: string;
@@ -14,12 +19,16 @@
interface Props { interface Props {
pages: TabPage[]; pages: TabPage[];
/** Currently active tab index (default: 0) */
activeIndex?: number; activeIndex?: number;
/** Callback fired when the active tab changes */
onchange?: (event: { index: number; tab: TabPage }) => void; onchange?: (event: { index: number; tab: TabPage }) => void;
/** Applies layout padding to the tab header & content areas (default: false) */
padded?: boolean;
class?: ClassValue | null; 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 primaryContainerEl: HTMLDivElement;
let tabContainerEl: HTMLDivElement; let tabContainerEl: HTMLDivElement;
@@ -88,7 +97,10 @@
<div bind:this={primaryContainerEl} class={[classValue]}> <div bind:this={primaryContainerEl} class={[classValue]}>
<div <div
bind:this={tabContainerEl} 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)} {#each pages as page, i (page.title)}
{@const active = activeIndex === i} {@const active = activeIndex === i}
@@ -122,7 +134,7 @@
{#key activeIndex} {#key activeIndex}
<div <div
class={[]} class={[padded && 'px-layout']}
in:flyX={{ direction: activeIndex > prevIndex ? 1 : -1, duration: 180, delay: 181 }} in:flyX={{ direction: activeIndex > prevIndex ? 1 : -1, duration: 180, delay: 181 }}
out:flyX={{ direction: activeIndex > prevIndex ? -1 : 1, duration: 180 }} out:flyX={{ direction: activeIndex > prevIndex ? -1 : 1, duration: 180 }}
onoutrostart={lockHeight} onoutrostart={lockHeight}

View File

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

View File

@@ -10,20 +10,41 @@ export interface GraphError {
export type RawError = Error | string | GraphError[]; export type RawError = Error | string | GraphError[];
export class ErrorMessage { export class ErrorMessage {
private _message: string; private _lines: string[] = [];
/** converts a RawError to a string and stores it for later access */ /**
constructor(raw: RawError) { * Converts a RawError to an array of lines and stores it for later access,
this._message = ErrorMessage.rawErrorToString(raw); * 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 */ /** returns the stored lines */
get message(): string { get lines(): string[] {
return this._message; return this._lines;
} }
/** returns the error as a string */ /** returns the error lines as a string, separated by newlines */
toString(): string { 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 */ /** optionally returns a new ErrorMessage only if the RawError is not empty */
@@ -32,28 +53,27 @@ export class ErrorMessage {
return new ErrorMessage(raw); return new ErrorMessage(raw);
} }
/** converts a RawError to a string */ /** converts a RawError to an array of lines */
static rawErrorToString(raw: RawError | null | undefined): string { static rawErrorToLines(raw: RawError | null | undefined): string[] {
if (!raw) return 'No error'; if (!raw) return ['No error'];
let errorString: string; let errorLines: string[];
if (typeof raw === 'string') { if (typeof raw === 'string') {
errorString = raw; errorLines = [raw];
} else if (raw instanceof Error) { } else if (raw instanceof Error) {
errorString = raw.message; errorLines = [raw.message];
} else if (Array.isArray(raw)) { } else if (Array.isArray(raw)) {
errorString = raw errorLines = raw.map((e) => {
.flatMap((e) => {
const messageString = e.message || 'Unknown error'; const messageString = e.message || 'Unknown error';
if (e.path && e.path.length > 0) { if (e.path && e.path.length > 0) {
return `"${messageString}" at ${e.path.join('.')}`; return `"${messageString}" at ${e.path.join('.')}`;
} }
}) return messageString;
.join('<br />'); });
} else { } else {
throw `Bad error value ${raw}`; throw `Bad error value ${raw}`;
} }
return errorString; return errorLines;
} }
} }

View File

@@ -1,11 +1,12 @@
// Reexport your entry components here // Reexport your entry components here
export { default as ActionSelect, type ActionSelectOption } from './ActionSelect.svelte'; export { default as ActionSelect, type ActionSelectOption } from './ActionSelect.svelte';
export { default as Banner, type BannerControls, type BannerAPI } from './Banner.svelte';
export { default as Button } from './Button.svelte'; export { default as Button } from './Button.svelte';
export { default as CenterBox } from './CenterBox.svelte'; export { default as CenterBox } from './CenterBox.svelte';
export { default as Checkbox, type CheckboxState } from './Checkbox.svelte'; export { default as Checkbox, type CheckboxState } from './Checkbox.svelte';
export { default as Combobox, type ComboboxOption } from './Combobox.svelte'; export { default as Combobox, type ComboboxOption } from './Combobox.svelte';
export { default as DateInput } from './DateInput.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 } from './Dialog.svelte';
export { export {
default as DurationInput, default as DurationInput,
formatDuration, formatDuration,
@@ -22,6 +23,7 @@ export { default as Link, rewriteHref } from './Link.svelte';
export { default as PhoneInput } from './PhoneInput.svelte'; export { default as PhoneInput } from './PhoneInput.svelte';
export { default as PinInput } from './PinInput.svelte'; export { default as PinInput } from './PinInput.svelte';
export { default as RadioGroup } from './RadioGroup.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 Spinner } from './Spinner.svelte';
export { default as StateMachine, type StateMachinePage } from './StateMachine.svelte'; export { default as StateMachine, type StateMachinePage } from './StateMachine.svelte';
export { default as StyledRawInput } from './StyledRawInput.svelte'; export { default as StyledRawInput } from './StyledRawInput.svelte';

View File

@@ -17,6 +17,15 @@
monospace 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 */ /* Primary Colors */
--color-sui-primary-50: var(--ui-primary-50, #f0f8fe); --color-sui-primary-50: var(--ui-primary-50, #f0f8fe);
--color-sui-primary-100: var(--ui-primary-100, #ddeefc); --color-sui-primary-100: var(--ui-primary-100, #ddeefc);

View File

@@ -143,3 +143,55 @@ export const trimEdges = (str: string, char: string, trimStart?: boolean, trimEn
return str.substring(start, end); return str.substring(start, end);
}; };
// helper: only treat plain objects as mergeable
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' &&
v !== null &&
!Array.isArray(v) &&
Object.getPrototypeOf(v) === Object.prototype;
/** Merge two plain object maps. No `any` used. */
function mergePlainObjects(
baseObj: Record<string, unknown>,
overrideObj: Record<string, unknown>
): Record<string, unknown> {
const res: Record<string, unknown> = { ...baseObj };
for (const k of Object.keys(overrideObj)) {
const v = overrideObj[k];
if (v === undefined) continue; // undefined preserves base
const b = res[k];
if (isPlainObject(v) && isPlainObject(b)) {
res[k] = mergePlainObjects(b as Record<string, unknown>, v as Record<string, unknown>);
} else {
// primitives, null, arrays, non-plain objects replace base
res[k] = v;
}
}
return res;
}
/**
* Merge `base` with `override`.
* - `null` in `override` replaces (kept as valid override)
* - `undefined` in `override` is ignored (keeps base)
* - Only plain objects are deep-merged
* - If `override` is null/undefined we return a shallow copy of `base`
*/
export const mergeOverrideObject = <T extends Record<string, unknown>>(
base: T,
override?: Partial<T> | null
): T => {
if (override == null) return { ...base } as T;
// Use plain maps internally to avoid explicit any
const baseMap = { ...base } as Record<string, unknown>;
const overrideMap = override as Record<string, unknown>;
const merged = mergePlainObjects(baseMap, overrideMap);
return merged as T;
};

View File

@@ -39,6 +39,7 @@
import ErrorBox from '$lib/ErrorBox.svelte'; import ErrorBox from '$lib/ErrorBox.svelte';
import TextareaInput from '$lib/TextareaInput.svelte'; import TextareaInput from '$lib/TextareaInput.svelte';
import DurationInput, { formatDuration } from '$lib/DurationInput.svelte'; import DurationInput, { formatDuration } from '$lib/DurationInput.svelte';
import Banner from '$lib/Banner.svelte';
// Lazy-load heavy components // Lazy-load heavy components
let PhoneInput = createLazyComponent(() => import('$lib/PhoneInput.svelte')); let PhoneInput = createLazyComponent(() => import('$lib/PhoneInput.svelte'));
@@ -56,7 +57,7 @@
{ value: 'option3', label: 'Option 3', disabled: true } { value: 'option3', label: 'Option 3', disabled: true }
]; ];
let lazyOptions: ComboboxOption[] = $state([]); let lazyOptions: ComboboxOption[] = $state([]);
let dateInputValue = $state<CalendarDate | undefined>(undefined); let dateInputValue = $state<CalendarDate | null>(null);
let checkboxValue = $state<CheckboxState>('indeterminate'); let checkboxValue = $state<CheckboxState>('indeterminate');
let dialogOpen = $state(false); let dialogOpen = $state(false);
let scrollableDialogOpen = $state(false); let scrollableDialogOpen = $state(false);
@@ -74,7 +75,25 @@
const boldStore = boldToggle.store; const boldStore = boldToggle.store;
</script> </script>
<title>sui</title> <svelte:head>
<title>sui</title>
</svelte:head>
<!-- Cookie Consent Banner Demo -->
<Banner
title="Manage Cookies"
controls={{
moreInfo: { label: 'More Info', type: 'link', href: '#!' },
dismiss: null,
swap: true
}}
onaccept={() => console.log('Cookies accepted!')}
ondecline={() => console.log('Cookies declined!')}
open
>
We use cookies and similar technologies to enhance your experience, analyze site traffic, and
measure our ads. You can manage your preferences anytime.
</Banner>
<h1 class="mb-4 text-3xl font-bold">sui — Opinionated Svelte 5 UI toolkit</h1> <h1 class="mb-4 text-3xl font-bold">sui — Opinionated Svelte 5 UI toolkit</h1>
@@ -410,6 +429,7 @@
<p class="title">Tabs</p> <p class="title">Tabs</p>
<Tabs <Tabs
padded={true}
pages={[ pages={[
{ {
title: 'Dashboard', title: 'Dashboard',
@@ -504,7 +524,8 @@
dialog.close(); dialog.close();
alert('Dialog submitted!'); alert('Dialog submitted!');
} }
} },
cancel: null
}} }}
onopen={(dialog) => { onopen={(dialog) => {
dialog.error('Example error message!'); dialog.error('Example error message!');