Compare commits

...

6 Commits

Author SHA1 Message Date
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
6 changed files with 266 additions and 5 deletions

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

@@ -0,0 +1,233 @@
<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;
};
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()
}
};
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
};
};
</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)}
>
{#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">
<!-- 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">
<!-- 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

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

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

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,23 @@
const boldStore = boldToggle.store; const boldStore = boldToggle.store;
</script> </script>
<svelte:head>
<title>sui</title> <title>sui</title>
</svelte:head>
<!-- Cookie Consent Banner Demo -->
<Banner
title="Manage Cookies"
controls={{
moreInfo: { label: 'More Info', type: 'link', href: '#!' }
}}
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>