Compare commits

19 Commits

Author SHA1 Message Date
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
Elijah Duffy
72b277d138 0.3.1 2025-12-12 13:59:35 -08:00
Elijah Duffy
3885ac09a1 link: refactor href rewrite
- uses prop instead of attempting to read env variable
- uses URL type instead of custom heuristics
2025-12-12 13:59:21 -08:00
Elijah Duffy
ae3abad769 use tinyduration for ISO8601 parsing and serializing 2025-12-11 20:40:27 -08:00
Elijah Duffy
837029b598 0.3.0 2025-12-11 17:30:36 -08:00
11 changed files with 489 additions and 105 deletions

View File

@@ -4,7 +4,7 @@
"type": "git",
"url": "https://gitea.auvem.com/svelte-toolkit/sui.git"
},
"version": "0.2.15",
"version": "0.3.5",
"scripts": {
"dev": "vite dev",
"build": "vite build && pnpm run prepack",
@@ -40,12 +40,12 @@
},
"peerDependencies": {
"@sveltejs/kit": "^2.20.2",
"svelte": "^5.0.0",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/vite": "^4.1.11"
"@tailwindcss/vite": "^4.1.11",
"svelte": "^5.0.0",
"tailwindcss": "^4.1.11",
"tailwindcss-animate": "^1.0.7"
},
"dependencies": {
"@internationalized/date": "^3.8.2",
@@ -56,7 +56,8 @@
"match-sorter": "^8.0.0",
"melt": "^0.37.0",
"moment": "^2.30.1",
"phosphor-svelte": "^3.0.1"
"phosphor-svelte": "^3.0.1",
"tinyduration": "^3.4.1"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",

8
pnpm-lock.yaml generated
View File

@@ -59,6 +59,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.1.11)
tinyduration:
specifier: ^3.4.1
version: 3.4.1
devDependencies:
'@eslint/compat':
specifier: ^1.2.5
@@ -1539,6 +1542,9 @@ packages:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'}
tinyduration@3.4.1:
resolution: {integrity: sha512-NemFoamVYn7TmtwZKZ3OiliM9fZkr6EWiTM+wKknco6POSy2gS689xx/pXip0JYp40HXpUw6k65CUYHWYUXdaA==}
tinyglobby@0.2.14:
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
engines: {node: '>=12.0.0'}
@@ -2917,6 +2923,8 @@ snapshots:
mkdirp: 3.0.1
yallist: 5.0.0
tinyduration@3.4.1: {}
tinyglobby@0.2.14:
dependencies:
fdir: 6.4.6(picomatch@4.0.3)

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

View File

@@ -26,12 +26,7 @@
* @returns The ISO 8601 duration string.
*/
export const durationToISO8601 = (duration: TimeDuration): string => {
let str = 'P';
if (duration.hours || duration.minutes || duration.seconds) str += 'T';
if (duration.hours) str += `${duration.hours}H`;
if (duration.minutes) str += `${duration.minutes}M`;
if (duration.seconds) str += `${duration.seconds}S`;
return str;
return serialize(duration);
};
/**
@@ -40,16 +35,7 @@
* @returns The TimeDuration object.
*/
export const iso8601ToDuration = (str: string): TimeDuration => {
const regex = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
const matches = str.match(regex);
if (!matches) {
throw new Error('Invalid ISO 8601 duration string');
}
return {
hours: matches[2] ? parseInt(matches[2], 10) : 0,
minutes: matches[3] ? parseInt(matches[3], 10) : 0,
seconds: matches[4] ? parseInt(matches[4], 10) : 0
};
return parse(str);
};
</script>
@@ -62,6 +48,7 @@
import Label from './Label.svelte';
import { liveValidator, validate } from '@svelte-toolkit/validate';
import StyledRawInput from './StyledRawInput.svelte';
import { parse, serialize } from 'tinyduration';
interface Props {
/**

View File

@@ -7,6 +7,7 @@
icon?: IconDef;
iconPosition?: 'left' | 'right';
disabled?: boolean;
inverted?: boolean;
class?: ClassValue | null | undefined;
children: Snippet;
onclick?: MouseEventHandler<HTMLButtonElement>;
@@ -16,6 +17,7 @@
icon,
iconPosition = 'right',
disabled = false,
inverted = false,
class: classValue,
children,
onclick
@@ -31,8 +33,11 @@
<button
type="button"
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',
inverted
? 'text-sui-background hover:text-sui-background/80 font-medium'
: 'text-sui-accent hover:text-sui-primary',
classValue
]}
{onclick}

View File

@@ -9,6 +9,6 @@
let { class: classList, children }: Props = $props();
</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?.()}
</div>

View File

@@ -1,32 +1,46 @@
<script lang="ts" module>
import { env } from '$env/dynamic/public';
/**
* Rewrites the href based on a given basepath.
* If the href is absolute, it is returned as is.
* If the href is relative, the basepath is prepended.
* @param href The original href.
* @returns The rewritten href.
*/
export const rewriteHref = (href: string, basepath?: string | null): string => {
// If no base path is set, return the href as is
if (!basepath) return href;
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++;
// Use URL API to determine if href is relative or absolute
try {
// this will only succeed if href is absolute
const independentUrl = new URL(href);
return independentUrl.toString();
} catch {
// now we can assume that href is relative or entirely invalid
// test with a generic baseURI to see if it's valid relative
try {
const relativeUrl = new URL(href, 'http://example.com');
// if we reach here, it's a valid relative URL
const prefix = trimEdges(basepath, '/');
return `/${prefix}/${trimEdges(relativeUrl.pathname, '/', true, false)}`;
} catch {
throw new Error(`Attempted to rewrite invalid href: ${href}`);
}
}
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 { ClassValue, MouseEventHandler } from 'svelte/elements';
import { trimEdges } from './util';
interface Props {
href: string;
basepath?: string | null;
disabled?: boolean;
tab?: 'current' | 'new';
inverted?: boolean;
class?: ClassValue | null | undefined;
children: Snippet;
onclick?: MouseEventHandler<HTMLAnchorElement>;
@@ -34,26 +48,28 @@
let {
href,
basepath,
disabled = false,
tab = 'current',
inverted = false,
class: classValue,
children,
onclick
}: Props = $props();
if (PUBLIC_BASEPATH && !href.startsWith('http://') && !href.startsWith('https://')) {
let prefix = trim(PUBLIC_BASEPATH, '/');
href = `/${prefix}/${trim(href, '/', true, false)}`;
}
const computedHref = $derived(rewriteHref(href, basepath));
</script>
<a
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',
inverted
? 'text-sui-background hover:text-sui-background/80 font-medium'
: 'text-sui-accent hover:text-sui-primary',
classValue
]}
{href}
href={computedHref}
target={tab === 'new' ? '_blank' : undefined}
rel={tab === 'new' ? 'noopener noreferrer' : undefined}
{onclick}

View File

@@ -1,5 +1,6 @@
// Reexport your entry components here
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 CenterBox } from './CenterBox.svelte';
export { default as Checkbox, type CheckboxState } from './Checkbox.svelte';
@@ -18,7 +19,7 @@ export { default as InjectGoogleMaps } from './InjectGoogleMaps.svelte';
export { default as InjectUmami } from './InjectUmami.svelte';
export { default as InputGroup } from './InputGroup.svelte';
export { default as Label } from './Label.svelte';
export { default as Link } from './Link.svelte';
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';
@@ -47,7 +48,8 @@ export {
getValue,
targetMust,
capitalizeFirstLetter,
prefixZero
prefixZero,
trimEdges
} from './util';
export {
type ToolbarToggleState,

View File

@@ -120,3 +120,78 @@ export const prefixZero = (str: string): string => {
}
return str;
};
/**
* Trims the specified character from the start and/or end of the string.
* @param str The string to trim.
* @param char The character to trim.
* @param trimStart Whether to trim from the start of the string. Default: true.
* @param trimEnd Whether to trim from the end of the string. Default: true.
* @returns The trimmed string.
*/
export const trimEdges = (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);
};
// 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 TextareaInput from '$lib/TextareaInput.svelte';
import DurationInput, { formatDuration } from '$lib/DurationInput.svelte';
import Banner from '$lib/Banner.svelte';
// Lazy-load heavy components
let PhoneInput = createLazyComponent(() => import('$lib/PhoneInput.svelte'));
@@ -56,7 +57,7 @@
{ value: 'option3', label: 'Option 3', disabled: true }
];
let lazyOptions: ComboboxOption[] = $state([]);
let dateInputValue = $state<CalendarDate | undefined>(undefined);
let dateInputValue = $state<CalendarDate | null>(null);
let checkboxValue = $state<CheckboxState>('indeterminate');
let dialogOpen = $state(false);
let scrollableDialogOpen = $state(false);
@@ -74,7 +75,25 @@
const boldStore = boldToggle.store;
</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>
@@ -476,6 +495,23 @@
{/snippet}
</div>
<!-- Link with href rewriting -->
<div class="component">
<p class="title">Link (with href rewriting)</p>
<p class="mb-3">
href rewriting allows you to prepend a basepath to relative links, making it easier to manage
URLs in your application. It is recommended to wrap this element with your own, e.g. AppLink,
that automatically provides the basepath from your app's configuration.
</p>
<div class="flex flex-col gap-3">
<Link href="/about" basepath="/sui-demo">Go to About Page (with basepath)</Link>
<Link href="https://svelte.dev" basepath="/sui-demo">External Svelte Site</Link>
<Link href="contact">Contact Us (relative link, no basepath)</Link>
</div>
</div>
<!-- Regular Dialog Demo -->
<Dialog
bind:open={dialogOpen}
@@ -487,7 +523,8 @@
dialog.close();
alert('Dialog submitted!');
}
}
},
cancel: null
}}
onopen={(dialog) => {
dialog.error('Example error message!');