diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx index c4a5256..fcdaf32 100644 --- a/src/components/ui/combobox.tsx +++ b/src/components/ui/combobox.tsx @@ -1,11 +1,15 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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(null); - const [triggerWidth, setTriggerWidth] = useState(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 ( - + - + {emptyText} {nullable && ( - { - onValueChange(''); - setOpen(false); - }} - > - + choose('')}> + {nullLabel} )} - {options.map((option) => ( + {options.map((o) => ( { - onValueChange(option.value); - setOpen(false); - }} + key={o.value} + value={o.value} + keywords={[o.label, ...(o.keywords ?? [])]} + onSelect={() => choose(o.value)} > - + - {option.marker} + {o.marker} - {option.label} - {option.description && ( - {option.description} - )} + {o.label} + {o.description && {o.description}} diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx deleted file mode 100644 index c62a39c..0000000 --- a/src/components/ui/toast.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC - * - * 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>( - ({ className, ...props }, ref) => ( -
    - ), -); -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 & - VariantProps & { - open?: boolean; - onOpenChange?: (open: boolean) => void; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars ->(({ className, variant, open, onOpenChange, ...props }, ref) => ( -
  1. -)); -Toast.displayName = 'Toast'; - -const ToastClose = React.forwardRef>( - ({ className, ...props }, ref) => ( - - ), -); -ToastClose.displayName = 'ToastClose'; - -const ToastTitle = React.forwardRef>( - ({ className, ...props }, ref) => ( -
    - ), -); -ToastTitle.displayName = 'ToastTitle'; - -const ToastDescription = React.forwardRef>( - ({ className, ...props }, ref) =>
    , -); -ToastDescription.displayName = 'ToastDescription'; - -export { ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, toastVariants }; -export type { VariantProps }; diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx index 8bc0953..f94abcb 100644 --- a/src/components/ui/toaster.tsx +++ b/src/components/ui/toaster.tsx @@ -1,30 +1,64 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * 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 = { + 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 ( - - {visibleToasts.map(function ({ id, title, description, variant, open, onOpenChange, ...props }) { +
      + {toasts.map(({ id, title, description, variant = 'default' }) => { + const loud = variant === 'destructive'; return ( - +
    1. - {title && {title}} - {description && {description}} + {title &&
      {title}
      } + {description &&
      {description}
      }
      - onOpenChange?.(false)} /> - + +
    2. ); })} - +
    ); } diff --git a/src/hooks/use-toast.test.ts b/src/hooks/use-toast.test.ts new file mode 100644 index 0000000..de6d247 --- /dev/null +++ b/src/hooks/use-toast.test.ts @@ -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); + }); +}); diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts index 1bf2c31..7c5e280 100644 --- a/src/hooks/use-toast.ts +++ b/src/hooks/use-toast.ts @@ -1,193 +1,92 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * 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()`. `` 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; - } - | { - 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>(); +let shown: ToasterToast[] = []; +let lastId = 0; +const subscribers = new Set<() => void>(); +const timers = new Map>(); -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; - -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(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 };