Compare commits

..

14 Commits

Author SHA1 Message Date
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
8 changed files with 369 additions and 28 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.0", "version": "0.3.4",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build && pnpm run prepack", "build": "vite build && pnpm run prepack",

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

@@ -0,0 +1,259 @@
<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
};
const mergeBannerControls = (
defaults: BannerControls,
overrides?: BannerControls | null
): BannerControls => {
if (overrides == null) return { ...defaults };
const result: BannerControls = { ...defaults };
const keys: (keyof BannerControls)[] = ['accept', 'decline', 'moreInfo', 'dismiss', 'swap'];
for (const key of keys) {
const ov = overrides[key];
if (ov === null) {
// explicit disable
result[key] = null;
continue;
}
if (ov === undefined) continue; // keep default
if (key === 'swap') {
result.swap = ov as boolean;
continue;
}
// shallow merge individual control fields
result[key] = {
...(defaults[key] ?? {}),
...(ov as any)
} as any;
}
return result;
};
</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';
/**
* 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(mergeBannerControls(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

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

@@ -1,32 +1,46 @@
<script lang="ts" module> <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; // Use URL API to determine if href is relative or absolute
try {
const trim = (str: string, char: string, trimStart?: boolean, trimEnd?: boolean) => { // this will only succeed if href is absolute
let start = 0, const independentUrl = new URL(href);
end = str.length; return independentUrl.toString();
} catch {
if (trimStart || trimStart === undefined) { // now we can assume that href is relative or entirely invalid
while (start < end && str[start] === char) start++; // 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>
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { ClassValue, MouseEventHandler } from 'svelte/elements'; import type { ClassValue, MouseEventHandler } from 'svelte/elements';
import { trimEdges } from './util';
interface Props { interface Props {
href: string; href: string;
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>;
@@ -34,26 +48,28 @@
let { let {
href, href,
basepath,
disabled = false, disabled = false,
tab = 'current', tab = 'current',
inverted = false,
class: classValue, class: classValue,
children, children,
onclick onclick
}: Props = $props(); }: Props = $props();
if (PUBLIC_BASEPATH && !href.startsWith('http://') && !href.startsWith('https://')) { const computedHref = $derived(rewriteHref(href, basepath));
let prefix = trim(PUBLIC_BASEPATH, '/');
href = `/${prefix}/${trim(href, '/', true, false)}`;
}
</script> </script>
<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} href={computedHref}
target={tab === 'new' ? '_blank' : undefined} target={tab === 'new' ? '_blank' : undefined}
rel={tab === 'new' ? 'noopener noreferrer' : undefined} rel={tab === 'new' ? 'noopener noreferrer' : undefined}
{onclick} {onclick}

View File

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

View File

@@ -120,3 +120,26 @@ export const prefixZero = (str: string): string => {
} }
return str; 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);
};

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>
@@ -476,6 +495,23 @@
{/snippet} {/snippet}
</div> </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 --> <!-- Regular Dialog Demo -->
<Dialog <Dialog
bind:open={dialogOpen} bind:open={dialogOpen}