Write our own toasts and combobox #120
No files matched your search
@@ -1,11 +1,15 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2023 shadcn
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ChevronsUpDown, Check, X } from 'lucide-react';
|
||||
// Built like shadcn/ui's combobox example (MIT): a popover over a command
|
||||
// list. The props are the console's own, kept so no caller changes.
|
||||
|
||||
import { useRef, useState, type ReactNode } from 'react';
|
||||
import { Check, ChevronsUpDown, X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -15,26 +19,36 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
||||
export interface ComboboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
/** A second, quieter line under the label. */
|
||||
description?: string;
|
||||
/** Shown before the label, in the list and on the button. */
|
||||
marker?: ReactNode;
|
||||
/** More words the search matches, besides the label. */
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
options: ComboboxOption[];
|
||||
/** The chosen option's value; '' for none. */
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
/** Offers a first row that clears the choice (sends ''). */
|
||||
nullable?: boolean;
|
||||
nullLabel?: string;
|
||||
/** Called the first time the list opens, to load options lazily. */
|
||||
onFirstOpen?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** cmdk matches on item values; this one can't collide with an option's. */
|
||||
const NONE = '\u0000none';
|
||||
|
||||
/** A button that opens a searchable list of options and shows the one chosen. */
|
||||
export function Combobox({
|
||||
options,
|
||||
value,
|
||||
@@ -43,92 +57,71 @@ export function Combobox({
|
||||
searchPlaceholder = 'Search...',
|
||||
emptyText = 'No matches.',
|
||||
disabled,
|
||||
className,
|
||||
contentClassName,
|
||||
nullable,
|
||||
nullLabel = 'None',
|
||||
onFirstOpen,
|
||||
className,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [triggerWidth, setTriggerWidth] = useState<number | undefined>(undefined);
|
||||
const firstOpenRef = useRef(false);
|
||||
const opened = useRef(false);
|
||||
const chosen = options.find((o) => o.value === value);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && triggerRef.current) {
|
||||
setTriggerWidth(triggerRef.current.offsetWidth);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (next && !firstOpenRef.current) {
|
||||
firstOpenRef.current = true;
|
||||
const toggle = (next: boolean) => {
|
||||
if (next && !opened.current) {
|
||||
opened.current = true;
|
||||
onFirstOpen?.();
|
||||
}
|
||||
setOpen(next);
|
||||
};
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
const choose = (next: string) => {
|
||||
onValueChange(next);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover open={open} onOpenChange={toggle}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn('w-full justify-between font-normal', !selected && 'text-muted-foreground', className)}
|
||||
className={cn('w-full justify-between font-normal', !chosen && 'text-muted-foreground', className)}
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
{selected?.marker}
|
||||
{selected?.label ?? placeholder}
|
||||
{chosen?.marker}
|
||||
{chosen ? chosen.label : placeholder}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cn('p-0', contentClassName)}
|
||||
align="start"
|
||||
style={triggerWidth ? { width: triggerWidth } : undefined}
|
||||
>
|
||||
<PopoverContent align="start" className={cn('w-(--radix-popover-trigger-width) p-0', contentClassName)}>
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{nullable && (
|
||||
<CommandItem
|
||||
value="__null__"
|
||||
onSelect={() => {
|
||||
onValueChange('');
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4 opacity-50" />
|
||||
<CommandItem value={NONE} keywords={[nullLabel]} onSelect={() => choose('')}>
|
||||
<X className="mr-2 size-4 opacity-50" />
|
||||
<span className="text-muted-foreground">{nullLabel}</span>
|
||||
</CommandItem>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
{options.map((o) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
keywords={[option.label, ...(option.keywords ?? [])]}
|
||||
onSelect={() => {
|
||||
onValueChange(option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
key={o.value}
|
||||
value={o.value}
|
||||
keywords={[o.label, ...(o.keywords ?? [])]}
|
||||
onSelect={() => choose(o.value)}
|
||||
>
|
||||
<Check className={cn('mr-2 h-4 w-4', option.value === value ? 'opacity-100' : 'opacity-0')} />
|
||||
<Check className={cn('mr-2 size-4', o.value !== value && 'opacity-0')} />
|
||||
<span className="flex flex-1 items-center gap-2 truncate">
|
||||
{option.marker}
|
||||
{o.marker}
|
||||
<span className="flex flex-col truncate">
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
|
||||
)}
|
||||
<span className="truncate">{o.label}</span>
|
||||
{o.description && <span className="truncate text-xs text-muted-foreground">{o.description}</span>}
|
||||
</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ToastViewport = React.forwardRef<HTMLOListElement, React.HTMLAttributes<HTMLOListElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'pointer-events-none fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
ToastViewport.displayName = 'ToastViewport';
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-background text-foreground',
|
||||
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||
success: 'border-green-500 bg-background text-foreground',
|
||||
warning: 'border-yellow-500 bg-background text-foreground',
|
||||
info: 'border-blue-500 bg-background text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.HTMLAttributes<HTMLLIElement> &
|
||||
VariantProps<typeof toastVariants> & {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
>(({ className, variant, open, onOpenChange, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Toast.displayName = 'Toast';
|
||||
|
||||
const ToastClose = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
aria-label="Close"
|
||||
className={cn(
|
||||
'absolute right-1 top-1 rounded-md p-1 text-foreground/60 transition-colors hover:text-foreground focus:outline-none focus:ring-1 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className,
|
||||
)}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
),
|
||||
);
|
||||
ToastClose.displayName = 'ToastClose';
|
||||
|
||||
const ToastTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('text-sm font-semibold [&+div]:text-xs', className)} {...props} />
|
||||
),
|
||||
);
|
||||
ToastTitle.displayName = 'ToastTitle';
|
||||
|
||||
const ToastDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('text-sm opacity-90', className)} {...props} />,
|
||||
);
|
||||
ToastDescription.displayName = 'ToastDescription';
|
||||
|
||||
export { ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, toastVariants };
|
||||
export type { VariantProps };
|
||||
@@ -1,30 +1,64 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Toast, ToastClose, ToastDescription, ToastTitle, ToastViewport } from '@/components/ui/toast';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useToast, type ToastVariant } from '@/hooks/use-toast';
|
||||
|
||||
const VARIANT_CLASSES: Record<ToastVariant, string> = {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive: 'border-destructive bg-destructive text-destructive-foreground',
|
||||
success: 'border-green-500 bg-background text-foreground',
|
||||
warning: 'border-yellow-500 bg-background text-foreground',
|
||||
info: 'border-blue-500 bg-background text-foreground',
|
||||
};
|
||||
|
||||
/** Shows the toasts raised with `toast()`: top of the screen on phones, bottom right otherwise. */
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
const visibleToasts = toasts.filter((t) => t.open !== false);
|
||||
|
||||
const { toasts, dismiss } = useToast();
|
||||
return (
|
||||
<ToastViewport>
|
||||
{visibleToasts.map(function ({ id, title, description, variant, open, onOpenChange, ...props }) {
|
||||
<ol
|
||||
data-slot="toaster"
|
||||
className="pointer-events-none fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]"
|
||||
>
|
||||
{toasts.map(({ id, title, description, variant = 'default' }) => {
|
||||
const loud = variant === 'destructive';
|
||||
return (
|
||||
<Toast key={id} variant={variant} open={open} onOpenChange={onOpenChange} {...props}>
|
||||
<li
|
||||
key={id}
|
||||
data-slot="toast"
|
||||
data-variant={variant}
|
||||
data-state="open"
|
||||
className={cn(
|
||||
'pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all',
|
||||
'data-[state=open]:animate-in data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
VARIANT_CLASSES[variant],
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
{title && <div className="text-sm font-semibold [&+div]:text-xs">{title}</div>}
|
||||
{description && <div className="text-sm opacity-90">{description}</div>}
|
||||
</div>
|
||||
<ToastClose onClick={() => onOpenChange?.(false)} />
|
||||
</Toast>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
onClick={() => dismiss(id)}
|
||||
className={cn(
|
||||
'absolute right-1 top-1 rounded-md p-1 transition-colors focus:outline-none focus:ring-1',
|
||||
loud
|
||||
? 'text-red-300 hover:text-red-50 focus:ring-red-400 focus:ring-offset-red-600'
|
||||
: 'text-foreground/60 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ToastViewport>
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { currentToasts, dismissToast, toast } from './use-toast';
|
||||
|
||||
const titles = () => currentToasts().map((t) => t.title);
|
||||
|
||||
describe('toasts', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
dismissToast();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows the newest first, five at most', () => {
|
||||
for (let i = 1; i <= 7; i++) toast({ title: `t${i}` });
|
||||
expect(titles()).toEqual(['t7', 't6', 't5', 't4', 't3']);
|
||||
});
|
||||
|
||||
it('goes after five seconds, or its own duration', () => {
|
||||
toast({ title: 'short' });
|
||||
toast({ title: 'undo', duration: 10_000 });
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(titles()).toEqual(['undo']);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(titles()).toEqual([]);
|
||||
});
|
||||
|
||||
it('takes one down by its handle, or all of them', () => {
|
||||
const first = toast({ title: 'a' });
|
||||
toast({ title: 'b' });
|
||||
first.dismiss();
|
||||
expect(titles()).toEqual(['b']);
|
||||
toast({ title: 'c' });
|
||||
dismissToast();
|
||||
expect(titles()).toEqual([]);
|
||||
});
|
||||
|
||||
it('changes a toast in place, keeping its place and timer', () => {
|
||||
toast({ title: 'other' });
|
||||
const t = toast({ title: 'Saving', variant: 'info' });
|
||||
t.update({ title: 'Saved', variant: 'success' });
|
||||
expect(currentToasts()[0]).toMatchObject({ id: t.id, title: 'Saved', variant: 'success' });
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(titles()).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets a pushed-out toast's timer go quietly", () => {
|
||||
const old = toast({ title: 'old' });
|
||||
for (let i = 0; i < 5; i++) toast({ title: `n${i}` });
|
||||
old.dismiss();
|
||||
expect(titles()).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
+64
-165
@@ -1,193 +1,92 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
/**
|
||||
* The console's toasts: one list for the whole app, outside React, so any
|
||||
* module can raise a toast with `toast()`. `<Toaster />` shows the list.
|
||||
*/
|
||||
import { useSyncExternalStore, type ReactNode } from 'react';
|
||||
|
||||
const TOAST_LIMIT = 5;
|
||||
const TOAST_REMOVE_DELAY = 300;
|
||||
const TOAST_AUTO_DISMISS_MS = 5000;
|
||||
/** At most this many show at once; the oldest goes first. */
|
||||
const MAX_SHOWN = 5;
|
||||
/** How long a toast stays up, unless it asks for longer (an Undo toast does). */
|
||||
const DEFAULT_DURATION_MS = 5000;
|
||||
|
||||
type ToastVariant = 'default' | 'destructive' | 'success' | 'warning' | 'info';
|
||||
|
||||
type ToasterToast = {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
interface ToastInput {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
variant?: ToastVariant;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** inbuxa: how long it stays up, in ms; longer for one that offers Undo. */
|
||||
/** How long it stays up, in ms. */
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: 'ADD_TOAST';
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: 'UPDATE_TOAST';
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: 'DISMISS_TOAST';
|
||||
toastId?: ToasterToast['id'];
|
||||
}
|
||||
| {
|
||||
type: 'REMOVE_TOAST';
|
||||
toastId?: ToasterToast['id'];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
interface ToasterToast extends ToastInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let shown: ToasterToast[] = [];
|
||||
let lastId = 0;
|
||||
const subscribers = new Set<() => void>();
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
function publish(next: ToasterToast[]) {
|
||||
shown = next;
|
||||
for (const notify of subscribers) notify();
|
||||
}
|
||||
|
||||
function clearTimer(id: string) {
|
||||
clearTimeout(timers.get(id));
|
||||
timers.delete(id);
|
||||
}
|
||||
|
||||
/** Takes the toast down, or every toast when no id is given. */
|
||||
function dismiss(id?: string) {
|
||||
if (id === undefined) {
|
||||
for (const t of shown) clearTimer(t.id);
|
||||
publish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||
};
|
||||
|
||||
case 'DISMISS_TOAST': {
|
||||
const { toastId } = action;
|
||||
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
};
|
||||
}
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
clearTimer(id);
|
||||
if (shown.some((t) => t.id === id)) publish(shown.filter((t) => t.id !== id));
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// inbuxa: honor the toast's own duration (an Undo toast stays up longer).
|
||||
setTimeout(dismiss, props.duration ?? TOAST_AUTO_DISMISS_MS);
|
||||
|
||||
/** Shows a toast, newest on top. The handle takes it down or changes it. */
|
||||
function toast(input: ToastInput) {
|
||||
const id = String(++lastId);
|
||||
const kept = shown.slice(0, MAX_SHOWN - 1);
|
||||
for (const t of shown.slice(MAX_SHOWN - 1)) clearTimer(t.id);
|
||||
publish([{ ...input, id }, ...kept]);
|
||||
timers.set(
|
||||
id,
|
||||
setTimeout(() => dismiss(id), input.duration ?? DEFAULT_DURATION_MS),
|
||||
);
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
id,
|
||||
dismiss: () => dismiss(id),
|
||||
update: (changes: ToastInput) => publish(shown.map((t) => (t.id === id ? { ...t, ...changes, id } : t))),
|
||||
};
|
||||
}
|
||||
|
||||
function subscribe(notify: () => void) {
|
||||
subscribers.add(notify);
|
||||
return () => {
|
||||
subscribers.delete(notify);
|
||||
};
|
||||
}
|
||||
|
||||
/** The toasts on screen, newest first. */
|
||||
const currentToasts = () => shown;
|
||||
|
||||
/** The toasts on screen, with `toast` and `dismiss` for convenience. */
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||
};
|
||||
const toasts = useSyncExternalStore(subscribe, currentToasts, currentToasts);
|
||||
return { toasts, toast, dismiss };
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
export { useToast, toast, dismiss as dismissToast, currentToasts };
|
||||
export type { ToasterToast, ToastVariant };
|
||||
Reference in new issue
Block a user