Write our own toasts and combobox #120

Merged
jcoffey-dev merged 1 commits from phase1/toast-combobox into main 2026-10-19 00:25:22 +00:00
5 changed files with 219 additions and 328 deletions

No files matched your search

+46 -53
View File
@@ -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>
-94
View File
@@ -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 };
+50 -16
View File
@@ -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>
);
}
+59
View File
@@ -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
View File
@@ -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 };