add Banner component
This commit is contained in:
233
src/lib/Banner.svelte
Normal file
233
src/lib/Banner.svelte
Normal 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}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user