Compare commits

10 Commits

Author SHA1 Message Date
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
11 changed files with 214 additions and 85 deletions

View File

@@ -4,7 +4,7 @@
"type": "git",
"url": "https://gitea.auvem.com/svelte-toolkit/sui.git"
},
"version": "0.3.3",
"version": "1.0.0",
"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

@@ -39,7 +39,7 @@
| null;
dismiss?: Omit<BannerControlButton, 'framed'> | null;
/** if true, accept and decline buttons are swapped with more info */
swap?: boolean;
swap?: boolean | null;
};
const defaultBannerControls: BannerControls = {
@@ -58,21 +58,6 @@
},
swap: false
};
const mergeBannerControls = (
defaults: BannerControls,
overrides: BannerControls | null | undefined
): BannerControls => {
if (!overrides) return defaults;
return {
accept: overrides.accept ?? defaults.accept,
decline: overrides.decline ?? defaults.decline,
moreInfo: overrides.moreInfo ?? defaults.moreInfo,
dismiss: overrides.dismiss ?? defaults.dismiss,
swap: overrides.swap ?? defaults.swap
};
};
</script>
<script lang="ts">
@@ -82,6 +67,7 @@
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
@@ -118,7 +104,7 @@
children
}: Props = $props();
const controls = $derived(mergeBannerControls(defaultBannerControls, rawControls));
const controls = $derived(mergeOverrideObject(defaultBannerControls, rawControls));
const api: BannerAPI = {
open: () => (open = true),

View File

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

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

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

@@ -6,7 +6,7 @@ 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 } from './Dialog.svelte';
export {
default as DurationInput,
formatDuration,
@@ -23,6 +23,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

@@ -143,3 +143,55 @@ export const trimEdges = (str: string, char: string, trimStart?: boolean, trimEn
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

@@ -84,6 +84,7 @@
title="Manage Cookies"
controls={{
moreInfo: { label: 'More Info', type: 'link', href: '#!' },
dismiss: null,
swap: true
}}
onaccept={() => console.log('Cookies accepted!')}
@@ -428,6 +429,7 @@
<p class="title">Tabs</p>
<Tabs
padded={true}
pages={[
{
title: 'Dashboard',
@@ -522,7 +524,8 @@
dialog.close();
alert('Dialog submitted!');
}
}
},
cancel: null
}}
onopen={(dialog) => {
dialog.error('Example error message!');