import type { JSX } from 'solid-js'; import { createComponent, createContext, createEffect, createSignal, createUniqueId, mergeProps, onCleanup, useContext, } from 'solid-js'; import { omitProps } from 'solid-use/props'; import assert from '../../utils/assert'; import createDynamic from '../../utils/create-dynamic'; import type { DynamicProps, HeadlessProps, ValidConstructor } from '../../utils/dynamic-prop'; import { createTag } from '../../utils/namespace'; const TOAST_TAG = createTag('toast'); const TOASTER_TAG = createTag('toaster'); interface ToastContextData { ownerID: string; } const ToastContext = createContext(); function useToastContext(componentName: string): ToastContextData { const context = useContext(ToastContext); assert(context, new Error(`<${componentName}> must be used inside a `)); return context; } export type ToastProps = HeadlessProps; /** * One notification inside a {@link Toaster}. Carries `role="status"`, so it is * announced without stealing focus. * * Renders a `
` by default. * * @see {@link https://github.com/lxsmnsyc/terracotta/blob/main/docs/components/toast.md} */ export function Toast(props: ToastProps): JSX.Element { useToastContext('Toast'); return createDynamic( () => props.as ?? ('div' as T), mergeProps( TOAST_TAG, { role: 'status', 'aria-live': 'polite', }, omitProps(props, ['as']), ) as DynamicProps, ); } export type ToasterProps = HeadlessProps; /** * The region that holds the toasts. It does not read the queue for you: * subscribe with {@link useToaster} and map over the result yourself. * * Renders a `
` by default. * * @see {@link https://github.com/lxsmnsyc/terracotta/blob/main/docs/components/toast.md} */ export function Toaster(props: ToasterProps): JSX.Element { const ownerID = createUniqueId(); return createComponent(ToastContext.Provider, { value: { ownerID, }, get children() { return createDynamic( () => props.as ?? ('div' as T), mergeProps(TOASTER_TAG, omitProps(props, ['as'])) as DynamicProps, ); }, }); } export interface ToastData { id: string; data: T; } export type ToasterListener = (queue: ToastData[]) => void; /** * The queue behind a {@link Toaster}. Create one at module scope and share it, * so any code can push a toast with `create`, drop one with `remove`, or empty * the queue with `clear`. * * @see {@link https://github.com/lxsmnsyc/terracotta/blob/main/docs/components/toast.md} */ export class ToasterStore { private static toasterID = 0; private readonly id: number; private queue: ToastData[] = []; private readonly listeners = new Set>(); private toastID = 0; constructor() { this.id = ToasterStore.toasterID; ToasterStore.toasterID += 1; } subscribe(callback: ToasterListener): () => void { this.listeners.add(callback); return () => { this.listeners.delete(callback); }; } private notify(): void { const clone = [...this.queue]; for (const listener of this.listeners.keys()) { listener(clone); } } create(data: T): string { const id = `toast-${this.id}-${this.toastID}`; this.toastID += 1; this.queue.push({ id, data, }); this.notify(); return id; } remove(id: string): void { this.queue = this.queue.filter((item) => item.id !== id); this.notify(); } clear(): void { this.queue = []; this.notify(); } getQueue(): ToastData[] { return this.queue; } } /** * Subscribes to a {@link ToasterStore} and returns its queue as a signal. The * store is passed in rather than read from context, so the same queue can be * reached from anywhere in the app, including outside the component tree. * * @see {@link https://github.com/lxsmnsyc/terracotta/blob/main/docs/components/toast.md} */ export function useToaster(toaster: ToasterStore): () => ToastData[] { const [signal, setSignal] = createSignal(toaster.getQueue()); createEffect(() => { onCleanup( toaster.subscribe((queue) => { setSignal(queue); }), ); }); return signal; }