feat: polish

This commit is contained in:
Anatoly Antonov 2026-04-22 01:27:38 +09:00
parent 2e6177fe74
commit 4bd927bb4e
137 changed files with 6357 additions and 137560 deletions

View file

@ -0,0 +1,60 @@
<script lang="ts">
import { Card, CardHeader, CardBody, Button, Icon } from '@sveltestrap/sveltestrap';
import type { Snippet } from 'svelte';
/**
* Standardized collapsible panel card used by every side-panel feature.
* The header shows a title and a caret; clicking the header toggles body
* visibility. The `initialCollapsed` prop seeds state but callers can
* drive it externally via `bind:collapsed` if they want to persist state.
*/
interface Props {
title?: string;
collapsed?: boolean;
headerClass?: string;
bodyClass?: string;
variant?: 'primary' | 'secondary' | 'light';
headerExtra?: Snippet;
children?: Snippet;
}
let {
title = '',
collapsed = $bindable(false),
headerClass = '',
bodyClass = '',
variant = 'primary',
headerExtra,
children,
}: Props = $props();
function toggle() {
collapsed = !collapsed;
}
</script>
<Card class="mb-2 collapsible-card">
<CardHeader
class="d-flex justify-content-between align-items-center card-header p-1 px-3 bg-{variant} text-white {headerClass}"
style="cursor:pointer;">
<button
type="button"
class="d-flex w-100 justify-content-between align-items-center bg-transparent border-0 p-0 text-white"
style="width:100%;"
aria-expanded={!collapsed}
onclick={toggle}>
<b class="card-title mb-0 text-white p-0">{title}</b>
<span class="d-flex align-items-center gap-2">
{#if headerExtra}{@render headerExtra()}{/if}
<Button class="p-0" size="sm" color={variant}>
<Icon name={collapsed ? 'caret-left-fill' : 'caret-down-fill'} class="text-white" />
</Button>
</span>
</button>
</CardHeader>
{#if !collapsed}
<CardBody class={bodyClass}>
{@render children?.()}
</CardBody>
{/if}
</Card>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from '@sveltestrap/sveltestrap';
import type { Snippet } from 'svelte';
interface Props {
isOpen?: boolean;
title?: string;
confirmText?: string;
cancelText?: string;
confirmVariant?: string;
cancelVariant?: string;
onconfirm?: () => void;
oncancel?: () => void;
children?: Snippet;
}
let {
isOpen = $bindable(false),
title = 'Confirm',
confirmText = 'Confirm',
cancelText = 'Cancel',
confirmVariant = 'primary',
cancelVariant = 'secondary',
onconfirm,
oncancel,
children,
}: Props = $props();
function handleConfirm() {
onconfirm?.();
isOpen = false;
}
function handleCancel() {
oncancel?.();
isOpen = false;
}
</script>
<Modal {isOpen} toggle={handleCancel} fade={false} backdrop={true}>
<ModalHeader toggle={handleCancel}>{title}</ModalHeader>
<ModalBody>
{#if children}{@render children()}{/if}
</ModalBody>
<ModalFooter>
<Button color={cancelVariant} on:click={handleCancel}>{cancelText}</Button>
<Button color={confirmVariant} on:click={handleConfirm}>{confirmText}</Button>
</ModalFooter>
</Modal>

View file

@ -0,0 +1,56 @@
<script lang="ts">
interface Props {
value?: number | null;
onchange?: () => void;
valuePrefix?: string;
valueSuffix?: string;
emptyValue?: number | null;
emptyPlaceholder?: string;
}
let {
value = $bindable(),
onchange = () => {},
valuePrefix = '',
valueSuffix = '',
emptyValue = null,
emptyPlaceholder = '-',
}: Props = $props();
let editing = $state(false);
let inputEl: HTMLInputElement | undefined = $state();
$effect(() => {
if (editing && inputEl) inputEl.focus();
});
function start() {
editing = true;
}
function stop() {
editing = false;
onchange();
}
function keydown(event: KeyboardEvent) {
if (event.key === 'Enter') stop();
else if (event.key === 'Escape') editing = false;
}
</script>
<td onclick={start} onfocusin={start}>
{#if editing}
<input
type="number"
class="form-control form-control-sm border-0"
bind:this={inputEl}
bind:value
onblur={stop}
onkeydown={keydown} />
{:else if value === emptyValue || value === null || value === undefined}
<span class="text-muted">{emptyPlaceholder}</span>
{:else}
<span>{valuePrefix}{value}{valueSuffix}</span>
{/if}
</td>

View file

@ -0,0 +1,33 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
interface Props extends HTMLAttributes<HTMLDivElement> {
id?: string;
label?: string;
children?: Snippet;
}
let { id, label = '', class: className = '', children, ...rest }: Props = $props();
</script>
<div {id} class="label-group {className}" {...rest}>
<div class="btn btn-link p-0 d-flex align-items-center w-100 text-decoration-none label-header">
<div class="border-top" style="width: 10px;"></div>
<span class="small text-nowrap ms-2">{label}</span>
<div class="flex-fill border-top ms-2"></div>
</div>
<div class="p-2 border border-top-0 label-content">
{@render children?.()}
</div>
</div>
<style>
.label-header {
margin-bottom: -0.75em;
}
.label-content {
padding-top: 0.75em !important;
}
</style>

View file

@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from 'svelte';
/**
* Fixed-position side panel attached to the map. Stops mouse/touch
* propagation so interactions inside the panel don't pan the map.
*/
interface Props {
position?: 'left' | 'right';
children?: Snippet;
}
let { position = 'left', children }: Props = $props();
let element: HTMLDivElement | null = $state(null);
export function getElement(): HTMLDivElement | null {
return element;
}
function stop(e: Event) {
e.stopPropagation();
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={element}
class="panel-container-{position}"
onclick={stop}
ondblclick={stop}
onmousedown={stop}
ontouchstart={stop}
onwheel={stop}
role="complementary">
{@render children?.()}
</div>

View file

@ -0,0 +1,191 @@
<script lang="ts" generics="V">
import { onMount } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
type Option = { value: V; label: string };
interface Props extends HTMLAttributes<HTMLDivElement> {
id?: string;
options?: Option[];
selected?: V | null;
placeholder?: string;
searchPlaceholder?: string;
disabled?: boolean;
clearable?: boolean;
onChange?: (value: V | null) => void;
}
let {
id = 'select-searchable',
options = [],
selected = $bindable<V | null>(null),
placeholder = 'Select an option...',
searchPlaceholder = 'Search...',
disabled = false,
clearable = false,
onChange,
class: className = '',
...restProps
}: Props = $props();
let isOpen = $state(false);
let searchTerm = $state('');
let selectElement = $state<HTMLElement>();
let dropdownElement = $state<HTMLElement>();
let searchInput = $state<HTMLInputElement>();
let dropdownStyle = $state('');
let filteredOptions = $derived(
options.filter((o) => o.label.toLowerCase().includes(searchTerm.toLowerCase())),
);
let selectedLabel = $derived(options.find((o) => o.value === selected)?.label ?? '');
function positionDropdown() {
if (!selectElement || !dropdownElement) return;
const rect = selectElement.getBoundingClientRect();
const height = dropdownElement.offsetHeight;
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const placeBelow = spaceBelow >= height || spaceBelow >= spaceAbove;
dropdownStyle = [
'position:fixed',
`top:${placeBelow ? rect.bottom : 'auto'}${typeof rect.bottom === 'number' && placeBelow ? 'px' : ''}`,
`bottom:${placeBelow ? 'auto' : `${window.innerHeight - rect.top}px`}`,
`left:${rect.left}px`,
`min-width:${rect.width}px`,
].join(';');
}
function toggle() {
if (disabled) return;
isOpen = !isOpen;
if (isOpen) {
searchTerm = '';
queueMicrotask(() => {
positionDropdown();
searchInput?.focus();
});
}
}
function pick(option: Option) {
selected = option.value;
isOpen = false;
searchTerm = '';
onChange?.(option.value);
}
function clear(e: Event) {
e.stopPropagation();
selected = null;
onChange?.(null);
}
function handleOutside(e: MouseEvent) {
if (selectElement && !selectElement.contains(e.target as Node)) isOpen = false;
}
$effect(() => {
if (!isOpen) return;
const listener = () => positionDropdown();
window.addEventListener('scroll', listener, true);
window.addEventListener('resize', listener);
return () => {
window.removeEventListener('scroll', listener, true);
window.removeEventListener('resize', listener);
};
});
onMount(() => positionDropdown());
</script>
<svelte:window onclick={handleOutside} />
<div
bind:this={selectElement}
{id}
class="form-control form-select select-container {className}"
class:disabled
class:show={isOpen}
role="combobox"
aria-haspopup="listbox"
aria-expanded={isOpen}
tabindex={disabled ? -1 : 0}
onclick={toggle}
onkeydown={(e) => e.key === 'Enter' && toggle()}
{...restProps}>
<span class:text-muted={selected == null}>{selectedLabel || placeholder}</span>
{#if clearable && selected != null}
<button type="button" class="clear-btn" tabindex="-1" aria-label="Clear selection" onclick={clear}>
&#10005;
</button>
{/if}
{#if isOpen}
<div class="pt-0 dropdown-menu show" bind:this={dropdownElement} style={dropdownStyle}>
<div class="p-2">
<input
bind:this={searchInput}
type="text"
class="form-control form-control-sm"
placeholder={searchPlaceholder}
bind:value={searchTerm}
onclick={(e) => e.stopPropagation()} />
</div>
<div class="options-list">
{#each filteredOptions as option}
<button
type="button"
class="dropdown-item small"
class:active={option.value === selected}
onclick={(e) => {
e.stopPropagation();
pick(option);
}}
role="option"
aria-selected={option.value === selected}>
{option.label}
</button>
{:else}
<div class="dropdown-item text-muted disabled">No options found</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.select-container {
position: relative;
cursor: default;
}
.dropdown-menu {
z-index: 1000;
width: max-content;
}
.options-list {
max-height: 40vh;
overflow-y: auto;
}
.clear-btn {
position: absolute;
top: 50%;
right: 2rem;
transform: translateY(-50%);
background: none;
border: none;
color: #2a2a2a;
font-size: 1rem;
cursor: pointer;
z-index: 2;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.clear-btn:focus {
outline: none;
}
</style>

View file

@ -0,0 +1,56 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
interface Props extends HTMLAttributes<HTMLDivElement> {
id?: string;
label?: string;
expanded?: boolean;
children?: Snippet;
}
let {
id,
label = '',
expanded = $bindable(false),
class: className = '',
children,
...rest
}: Props = $props();
</script>
<div {id} class="spoiler-group {className}" {...rest}>
<button
type="button"
class="btn btn-link p-0 d-flex align-items-center w-100 text-decoration-none spoiler-header"
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}>
<span class="font-monospace fs-5 ms-1 fw-bold text-muted spoiler-icon">{expanded ? '' : '+'}</span>
<span class="small text-nowrap ms-1">{label}</span>
<div class="flex-fill border-top ms-1"></div>
</button>
{#if expanded}
<div class="p-2 border border-top-0 spoiler-content">
{@render children?.()}
</div>
{:else}
<div style="padding-top: 0.75em;"></div>
{/if}
</div>
<style>
.spoiler-header {
margin-bottom: -0.75em;
}
.spoiler-content {
padding-top: 0.75em !important;
}
.spoiler-icon {
line-height: 1;
padding-bottom: 0.1em;
}
.btn:hover .spoiler-icon {
color: var(--bs-dark) !important;
}
</style>

48
src/lib/ui/TabBar.svelte Normal file
View file

@ -0,0 +1,48 @@
<script lang="ts" generics="T extends string">
import { Icon } from '@sveltestrap/sveltestrap';
interface Tab {
id: T;
icon: string;
label: string;
}
interface Props {
tabs: Tab[];
active: T;
justify?: 'start' | 'center' | 'end';
}
let { tabs, active = $bindable(), justify = 'start' }: Props = $props();
</script>
<div class="d-flex justify-content-{justify} mb-1 gap-1">
{#each tabs as tab (tab.id)}
<button
type="button"
class="custom-tab btn btn-outline-primary d-flex flex-column align-items-center justify-content-center px-1"
class:active={active === tab.id}
onclick={() => (active = tab.id)}>
<Icon name={tab.icon} class="custom-tab-icon" />
<span class="custom-tab-label">{tab.label}</span>
</button>
{/each}
</div>
<style>
.custom-tab {
width: 4.5rem;
background: var(--bs-body-bg);
}
.custom-tab.active,
.custom-tab:hover {
background-color: var(--bs-primary) !important;
color: var(--bs-btn-active-color);
}
.custom-tab-label {
font-size: 0.66rem;
font-weight: 500;
}
</style>

40
src/lib/ui/Toast.svelte Normal file
View file

@ -0,0 +1,40 @@
<script lang="ts">
import { Toast, ToastBody, ToastHeader, Icon } from '@sveltestrap/sveltestrap';
import { toasts, removeToast, type ToastColor } from './toasts';
const ICONS: Record<ToastColor, string> = {
primary: 'info-circle-fill',
secondary: 'info-circle-fill',
success: 'check-circle-fill',
danger: 'exclamation-triangle-fill',
warning: 'exclamation-circle-fill',
info: 'info-circle-fill',
light: 'lightbulb',
dark: 'question',
};
</script>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
{#each $toasts as toast (toast.id)}
<Toast
isOpen={true}
autohide={!toast.persistent}
delay={5000}
color={toast.color}
on:close={() => removeToast(toast.id)}>
<ToastHeader toggle={() => removeToast(toast.id)} class={`text-${toast.color}`}>
<Icon slot="icon" name={ICONS[toast.color]} class="me-2" color={toast.color} />
{toast.header}
</ToastHeader>
<ToastBody>
{toast.body}
</ToastBody>
</Toast>
{/each}
</div>
<style>
.toast-container {
z-index: 1090;
}
</style>

View file

@ -0,0 +1,338 @@
<script module lang="ts">
export interface ListEditorConfig {
showTable?: boolean;
closeOnSave?: boolean;
closeOnDelete?: boolean;
searchBy?: string[];
labels?: {
item?: string;
itemGenitive?: string;
items?: string;
add?: string;
edit?: string;
save?: string;
update?: string;
delete?: string;
cancel?: string;
close?: string;
searchPlaceholder?: string;
};
}
export interface ListEditorApi<T> {
save: (item: T) => Promise<T>;
update: (item: T) => Promise<T>;
delete: (item: T) => Promise<void>;
}
</script>
<script lang="ts" generics="T extends { id: number; name: string }">
import { TableHandler } from '@vincjo/datatables';
import {
Modal,
Button,
Alert,
Icon,
Pagination,
PaginationItem,
PaginationLink,
Input,
InputGroup,
} from '@sveltestrap/sveltestrap';
import { addToast } from '../toasts';
import ConfirmationPrompt from '../ConfirmationPrompt.svelte';
import type { Snippet } from 'svelte';
interface Props {
isOpen?: boolean;
items?: T[];
itemFactory: () => T;
api: ListEditorApi<T>;
config?: ListEditorConfig;
onClose?: () => void;
onSave?: (item: T) => void;
onSelect?: (item: T) => void;
tableHeader: Snippet;
tableRow: Snippet<[{ row: T }]>;
formFields: Snippet<[{ item: T }]>;
}
const DEFAULT_LABELS = {
item: 'item',
itemGenitive: 'item',
items: 'items',
add: 'Add',
edit: 'Edit',
save: 'Save',
update: 'Update',
delete: 'Delete',
cancel: 'Cancel',
close: 'Close',
searchPlaceholder: 'Search...',
};
let {
isOpen = $bindable(false),
items = $bindable([] as T[]),
itemFactory,
api,
config = {},
onClose = () => {},
onSave = (_i: T) => {},
onSelect = (_i: T) => {},
tableHeader,
tableRow,
formFields,
}: Props = $props();
const labels = $derived({ ...DEFAULT_LABELS, ...(config.labels ?? {}) });
const searchBy = $derived((config.searchBy ?? ['name']) as (keyof T)[]);
const showTableProp = $derived(config.showTable ?? false);
let isEditing = $state(false);
let isAlertVisible = $state(false);
let isConfirmationVisible = $state(false);
let isTableVisible = $state(config.showTable ?? false);
let alertText = $state('');
let selectedItem = $state<T | null>(null);
let currentItem = $state<T>(itemFactory());
const table = $derived(new TableHandler(items, { rowsPerPage: 10 }));
const search = $derived(table.createSearch(searchBy));
$effect(() => {
table.setRows(items);
});
export function open(item: T | null = null, showTable: boolean = showTableProp) {
if (item) {
handleEdit(item);
} else {
resetForm(false);
}
isOpen = true;
isTableVisible = showTable;
}
function close() {
isOpen = false;
onClose();
}
function handleEdit(item: T) {
selectedItem = item;
currentItem = { ...item };
isEditing = true;
}
function resetForm(clearSelection = true) {
if (clearSelection) selectedItem = null;
currentItem = itemFactory();
isEditing = false;
hideAlert();
}
function handleSelect(item: T) {
onSelect(item);
close();
}
async function handleSave() {
try {
if (isEditing && selectedItem) {
const updated = await api.update(currentItem);
items = items.map((i) => (i.id === updated.id ? updated : i));
toastOk('updated', updated.name);
onSave(updated);
} else {
const saved = await api.save(currentItem);
items = [...items, saved];
toastOk('saved', saved.name);
onSave(saved);
}
resetForm();
if (config.closeOnSave) close();
} catch (error: unknown) {
showAlert(`Error: ${(error as Error).message}`);
}
}
function confirmDelete(item: T) {
selectedItem = item;
isConfirmationVisible = true;
}
async function handleDelete() {
if (!selectedItem) return;
try {
await api.delete(selectedItem);
items = items.filter((i) => i.id !== selectedItem!.id);
toastOk('deleted', selectedItem.name);
if (config.closeOnDelete) close();
} catch (error: unknown) {
showAlert(`Error: ${(error as Error).message}`);
} finally {
isConfirmationVisible = false;
resetForm();
}
}
function showAlert(message: string) {
isAlertVisible = true;
alertText = message;
}
function hideAlert() {
isAlertVisible = false;
alertText = '';
}
function toastOk(action: string, name: string) {
const itemLabel = labels.item.charAt(0).toUpperCase() + labels.item.slice(1);
addToast({
header: `${itemLabel} ${action}`,
body: `${itemLabel} "${name}" ${action}.`,
color: 'success',
});
}
const modalTitle = $derived(
isEditing
? `${labels.edit} ${labels.itemGenitive}`
: isTableVisible
? `${labels.items}`
: `${labels.add} ${labels.itemGenitive}`,
);
const submitButtonText = $derived(isEditing ? labels.update : labels.save);
</script>
<Modal
{isOpen}
toggle={close}
size="lg"
fade={false}
backdrop={true}
scrollable
class={isConfirmationVisible ? 'modal-tinted' : ''}>
<div class="modal-header">
<h5 class="modal-title">{modalTitle}</h5>
<button type="button" class="btn-close" onclick={close} aria-label="Close"></button>
</div>
<div class="modal-body">
{#if isTableVisible}
<div class="position-relative mb-2">
<Input
type="text"
class="form-control-sm pe-5"
placeholder={labels.searchPlaceholder}
bind:value={search.value}
oninput={() => search.set()} />
{#if search.value}
<Button
size="sm"
color="white"
class="position-absolute top-50 end-0 translate-middle-y me-2 rounded-circle d-flex align-items-center justify-content-center"
style="width: 16px; height: 16px; border: none; background: var(--bs-secondary); color: var(--bs-white);"
onclick={() => {
search.value = '';
search.set();
}}>
<Icon name="x" style="font-size: 16px;" />
</Button>
{/if}
</div>
<div bind:this={table.element} class="table-responsive">
<table class="table table-sm">
<thead>
{@render tableHeader()}
</thead>
<tbody>
{#each table.rows as row (row.id)}
<tr>
{@render tableRow({ row: row as T })}
<td class="fit">
<InputGroup size="sm" class="d-flex gap-1 flex-nowrap">
<Button
size="sm"
color="secondary"
onclick={() => handleSelect(row as T)}
class="p-0 border-0 bg-transparent text-success px-1">
<Icon name="check-lg" />
</Button>
<Button
size="sm"
color="primary"
onclick={() => handleEdit(row as T)}
class="p-0 border-0 bg-transparent text-primary px-1">
<Icon name="pencil" />
</Button>
<Button
size="sm"
color="danger"
onclick={() => confirmDelete(row as T)}
class="p-0 border-0 bg-transparent text-danger px-1">
<Icon name="trash" />
</Button>
</InputGroup>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Pagination aria-label="Page navigation" size="sm">
<PaginationItem>
<PaginationLink previous onclick={() => table.setPage('previous')} />
</PaginationItem>
{#each table.pagesWithEllipsis as page}
<PaginationItem active={table.currentPage === page}>
<PaginationLink onclick={() => table.setPage(page)}>{page}</PaginationLink>
</PaginationItem>
{/each}
<PaginationItem>
<PaginationLink next onclick={() => table.setPage('next')} />
</PaginationItem>
</Pagination>
<hr />
{/if}
<div>
<Alert color="danger" isOpen={isAlertVisible} toggle={hideAlert} fade={false} class="mb-2">
<Icon name="exclamation-triangle" class="me-2" />
{alertText}
</Alert>
<form
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}>
{@render formFields({ item: currentItem })}
<div class="d-grid gap-2 d-md-flex mt-3">
<Button type="submit" color="success" size="sm">{submitButtonText}</Button>
{#if isEditing}
<Button size="sm" type="button" color="secondary" onclick={() => resetForm()}>
{labels.cancel}
</Button>
<Button color="danger" size="sm" type="button" onclick={() => confirmDelete(currentItem)}>
{labels.delete}
</Button>
{:else}
<Button color="secondary" size="sm" type="button" onclick={close}>{labels.close}</Button>
{/if}
</div>
</form>
</div>
</div>
</Modal>
<ConfirmationPrompt
bind:isOpen={isConfirmationVisible}
title={`${labels.delete} ${labels.itemGenitive}`}
confirmText={labels.delete}
cancelText={labels.cancel}
confirmVariant="danger"
onconfirm={handleDelete}
oncancel={() => (isConfirmationVisible = false)}>
<p>Delete "{selectedItem?.name}"?</p>
</ConfirmationPrompt>

13
src/lib/ui/index.ts Normal file
View file

@ -0,0 +1,13 @@
export { default as CollapsibleCard } from './CollapsibleCard.svelte';
export { default as PanelContainer } from './PanelContainer.svelte';
export { default as TabBar } from './TabBar.svelte';
export { default as Toast } from './Toast.svelte';
export { default as ConfirmationPrompt } from './ConfirmationPrompt.svelte';
export { default as SelectSearchable } from './SelectSearchable.svelte';
export { default as SpoilerGroup } from './SpoilerGroup.svelte';
export { default as LabelGroup } from './LabelGroup.svelte';
export { default as EditableCell } from './EditableCell.svelte';
export { default as ListEditor } from './editor/ListEditor.svelte';
export type { ListEditorConfig, ListEditorApi } from './editor/ListEditor.svelte';
export { addToast, removeToast, toasts } from './toasts';
export type { Toast as ToastMessage, ToastColor, ToastInit } from './toasts';

54
src/lib/ui/toasts.ts Normal file
View file

@ -0,0 +1,54 @@
import { writable } from 'svelte/store';
export type ToastColor =
| 'primary'
| 'secondary'
| 'success'
| 'danger'
| 'warning'
| 'info'
| 'light'
| 'dark';
export interface Toast {
id: string;
header: string;
body: string;
color: ToastColor;
persistent: boolean;
onRemove?: (id: string) => void;
}
export interface ToastInit {
header: string;
body: string;
color?: ToastColor;
persistent?: boolean;
onRemove?: (id: string) => void;
}
export const toasts = writable<Toast[]>([]);
export function addToast(init: ToastInit): string {
const id = crypto.randomUUID();
toasts.update((all) => [
...all,
{
id,
header: init.header,
body: init.body,
color: init.color ?? 'info',
persistent: init.persistent ?? false,
onRemove: init.onRemove,
},
]);
return id;
}
export function removeToast(id: string): void {
toasts.update((all) => {
const t = all.find((x) => x.id === id);
t?.onRemove?.(id);
return all.filter((x) => x.id !== id);
});
}