235 lines
5.9 KiB
Svelte
235 lines
5.9 KiB
Svelte
<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)}
|
|
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">
|
|
<!-- 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="ml-auto 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}
|