add Banner component

This commit is contained in:
Elijah Duffy
2025-12-15 15:24:53 -08:00
parent 63b29e3f6a
commit 9782a31846
3 changed files with 248 additions and 0 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

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

@@ -79,6 +79,20 @@
<title>sui</title> <title>sui</title>
</svelte:head> </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>
<p class="mb-4"> <p class="mb-4">