@kitn.ai/ui
Version:
Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.
277 lines (251 loc) • 11.4 kB
text/typescript
// Module-level toast store + the imperative `toast()` API.
//
// This is the PRIMARY way consumers raise a toast: `toast('Saved')`,
// `toast.success('Copied')`, `toast.dismiss(id)`. The store is a single
// reactive list held in a long-lived `createRoot` (so its reactivity survives
// outside any component), and `ensureMounted()` lazily creates exactly ONE
// `<kai-toast-region>` on `document.body` the first time a toast is raised,
// binding the list to its `toasts` property. The region is a real `kai-*`
// element, so it carries its own shadow root + the shared kit stylesheet — it's
// viewport-positioned AND fully kit-styled, never a raw div.
//
// SSR-safe: every DOM touch is guarded by `typeof document`. On the server,
// raising a toast is an inert no-op (the store updates, nothing mounts).
import { createRoot, createSignal } from 'solid-js';
import { createStore, produce } from 'solid-js/store';
import type { ToastPosition } from '../components/toast';
export type ToastVariant = 'neutral' | 'success' | 'warning' | 'error' | 'info';
/** Visual treatment: `'pill'` (default, compact single-line) or `'card'` (richer
* rounded card with an optional description line). */
export type ToastAppearance = 'pill' | 'card';
export interface ToastConfig {
stack?: 'expanded' | 'collapsed';
position?: ToastPosition;
max?: number;
/** Default appearance for imperatively-raised toasts. Defaults to `'pill'`. */
appearance?: ToastAppearance;
/** Default high-contrast inverse treatment for imperatively-raised toasts.
* Defaults to `false`. */
inverse?: boolean;
}
// Module-level config the imperative singleton regions inherit. Defaults match
// the element's own defaults, so an un-configured app behaves exactly as before.
let toastConfig: ToastConfig = {};
/** Apply the current config to a region element (attributes for scalars). */
function applyConfig(el: HTMLElement): void {
if (toastConfig.stack) el.setAttribute('stack', toastConfig.stack);
if (toastConfig.position) el.setAttribute('position', toastConfig.position);
if (toastConfig.max !== undefined) el.setAttribute('max', String(toastConfig.max));
if (toastConfig.appearance) el.setAttribute('appearance', toastConfig.appearance);
if (toastConfig.inverse !== undefined) {
if (toastConfig.inverse) el.setAttribute('inverse', '');
else el.removeAttribute('inverse');
}
}
/**
* Configure the imperative `toast()` singleton — call once at app start.
* `toast.success('…')` has no element to set a prop on, so this is how you opt
* the auto-mounted region into collapsed stacking / a position / a max. Updates
* any already-mounted regions too, so call order doesn't matter.
*/
export function configureToasts(config: ToastConfig): void {
toastConfig = { ...toastConfig, ...config };
if (typeof document === 'undefined') return;
document.querySelectorAll('kai-toast-region').forEach((el) => applyConfig(el as HTMLElement));
}
/** An action button rendered inside the toast. Returning `false` from
* `onAction` keeps the toast open (e.g. to show a follow-up); any other
* return value dismisses it. */
export interface ToastAction {
label: string;
onAction: () => void | false;
}
export interface ToastItem {
id: string;
message: string;
variant?: ToastVariant;
/** Visual treatment: `'pill'` (default, compact single-line) or `'card'` (richer
* rounded card with an optional description line). */
appearance?: ToastAppearance;
/** High-contrast inverse surface — works on either appearance, popping in light
* AND dark. Defaults to `false`. */
inverse?: boolean;
/** Secondary line shown below the message in the `'card'` appearance. The
* `'pill'` appearance ignores it. */
description?: string;
action?: ToastAction;
/** Auto-dismiss delay in ms. `0` = sticky (never auto-dismisses). When an
* `action` is present the effective floor is 4000ms so it stays long enough
* to act on. Defaults to 2000ms. */
duration?: number;
/** Whether the × close affordance is shown. Defaults to `true`. */
dismissible?: boolean;
/** Container to scope this toast WITHIN — it floats anchored to that element's
* bounds instead of the viewport. Omit for a global, viewport-anchored toast.
* The chat targets itself by default so its copy/feedback toasts stay in-chat. */
target?: HTMLElement;
}
/** Options accepted by `toast()` — everything but the message. Pass `id` to
* update an existing toast in place. */
export interface ToastOptions {
id?: string;
variant?: ToastVariant;
/** Visual treatment: `'pill'` (default) or `'card'`. Falls back to the value set
* via `configureToasts`, then `'pill'`. */
appearance?: ToastAppearance;
/** High-contrast inverse surface. Falls back to `configureToasts`, then `false`. */
inverse?: boolean;
/** Secondary line shown below the message in the `'card'` appearance. */
description?: string;
action?: ToastAction;
duration?: number;
dismissible?: boolean;
/** Scope this toast to a container's bounds (e.g. a chat) instead of the
* viewport. Omit for a global, viewport-anchored toast. */
target?: HTMLElement;
}
/** Handle returned from `toast()` for imperative control. */
export interface ToastHandle {
id: string;
dismiss: () => void;
update: (patch: Partial<Omit<ToastItem, 'id'>>) => void;
}
/** Default auto-dismiss delay. Long enough to read + reach before it leaves. */
export const DEFAULT_TOAST_DURATION = 5000;
/** Minimum auto-dismiss delay when the toast carries an action (e.g. Undo) — it
* has to stay up long enough to actually act on. */
export const ACTION_TOAST_FLOOR = 7000;
// ── The reactive store ──────────────────────────────────────────────────────
// Held in a never-disposed root so the signal/store graph stays live for the
// lifetime of the page, independent of any component.
const { toasts, setToasts, mounted, setMounted } = createRoot(() => {
const [list, setList] = createStore<ToastItem[]>([]);
const [isMounted, setIsMounted] = createSignal(false);
return { toasts: list, setToasts: setList, mounted: isMounted, setMounted: setIsMounted };
});
/** The live toast list (reactive). The region facade binds to this. */
export function getToasts(): ToastItem[] {
return toasts;
}
let counter = 0;
function nextId(): string {
counter += 1;
return `toast-${counter}`;
}
/** Resolve the effective duration, applying the action floor. */
function resolveDuration(item: Pick<ToastItem, 'duration' | 'action'>): number {
const base = item.duration ?? DEFAULT_TOAST_DURATION;
if (base === 0) return 0; // explicit sticky wins
if (item.action) return Math.max(base, ACTION_TOAST_FLOOR);
return base;
}
/**
* Lazily create the single `<kai-toast-region>` host on `document.body` and
* bind the store to its `toasts` property. Idempotent + SSR-safe. The element
* upgrade (its `defineWebComponent` registration) happens when the elements
* bundle loads; setting the property before/after upgrade both work because
* `customElement` reflects late-set properties.
*/
// One region per distinct target (the `null` key = the global / viewport region).
// Every region binds the SAME store proxy and filters to its own target, so a
// toast routes to the region for its `target`.
const regions = new Map<HTMLElement | null, HTMLElement>();
export function ensureMounted(target: HTMLElement | null = null): HTMLElement | undefined {
if (typeof document === 'undefined') return undefined;
const cached = regions.get(target);
if (cached && cached.isConnected) return cached;
const el = document.createElement('kai-toast-region') as HTMLElement;
// Append FIRST so the element upgrades, THEN bind. Setting a property before
// upgrade loses it: component-register resets every prop in the constructor on
// upgrade (the same pre-upgrade clobber that bites <kai-chat>), so a pre-append
// `el.toasts = …` is wiped and the first toast never renders.
document.body.appendChild(el);
(el as unknown as { toasts: ToastItem[] }).toasts = toasts as ToastItem[];
if (target) (el as unknown as { target: HTMLElement }).target = target;
applyConfig(el); // inherit stack/position/max from configureToasts()
regions.set(target, el);
setMounted(true);
return el;
}
/** Test-only: whether `ensureMounted` has run. */
export function isToastRegionMounted(): boolean {
return mounted();
}
function upsert(item: ToastItem): void {
const idx = toasts.findIndex((t) => t.id === item.id);
if (idx >= 0) {
setToasts(idx, item);
} else {
setToasts(produce((list) => { list.push(item); }));
}
}
function dismiss(id: string): void {
setToasts((list) => list.filter((t) => t.id !== id));
}
function makeHandle(id: string): ToastHandle {
return {
id,
dismiss: () => dismiss(id),
update: (patch) => {
const idx = toasts.findIndex((t) => t.id === id);
if (idx >= 0) setToasts(idx, (prev) => ({ ...prev, ...patch }));
},
};
}
/** Internal: raise/refresh a toast of a given variant. */
function raise(message: string, opts: ToastOptions | undefined, variant: ToastVariant): ToastHandle {
ensureMounted(opts?.target ?? null);
const id = opts?.id ?? nextId();
const item: ToastItem = {
id,
message,
variant: opts?.variant ?? variant,
appearance: opts?.appearance ?? toastConfig.appearance ?? 'pill',
inverse: opts?.inverse ?? toastConfig.inverse ?? false,
description: opts?.description,
action: opts?.action,
duration: opts?.duration,
dismissible: opts?.dismissible,
target: opts?.target,
};
upsert(item);
return makeHandle(id);
}
export interface ToastFn {
(message: string, opts?: ToastOptions): ToastHandle;
/** Raise a success (green check) toast. */
success: (message: string, opts?: ToastOptions) => ToastHandle;
/** Raise a warning (amber) toast — e.g. an agent needs your input. */
warning: (message: string, opts?: ToastOptions) => ToastHandle;
/** Raise an error (destructive/red) toast — e.g. an agent failed. */
error: (message: string, opts?: ToastOptions) => ToastHandle;
/** Raise an info (blue) toast. */
info: (message: string, opts?: ToastOptions) => ToastHandle;
/** Dismiss a toast by id. */
dismiss: (id: string) => void;
/** Dismiss every active toast. */
clear: () => void;
}
/**
* Raise a transient toast. Returns a `{ id, dismiss, update }` handle.
*
* ```ts
* toast('Copied to clipboard');
* toast.success('Saved');
* const t = toast('Working…', { duration: 0 });
* t.update({ message: 'Done', variant: 'success', duration: 2000 });
* ```
*/
export const toast: ToastFn = Object.assign(
(message: string, opts?: ToastOptions) => raise(message, opts, 'neutral'),
{
success: (message: string, opts?: ToastOptions) => raise(message, opts, 'success'),
warning: (message: string, opts?: ToastOptions) => raise(message, opts, 'warning'),
error: (message: string, opts?: ToastOptions) => raise(message, opts, 'error'),
info: (message: string, opts?: ToastOptions) => raise(message, opts, 'info'),
dismiss,
clear: () => setToasts([]),
},
);
// Re-export for the region facade so the effective-duration rule lives in one place.
export { resolveDuration };