mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-09 05:07:53 +00:00
feat(US-910): Help context windows + keyboard shortcut fix
This commit is contained in:
parent
f87fe0f8af
commit
b243728f08
8 changed files with 466 additions and 46 deletions
|
|
@ -22,3 +22,6 @@
|
|||
{"type":"task.status_changed","taskId":"task_20260128_AaRWcX","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_I6-ezwZLEq7a","timestamp":"2026-01-28T02:34:13.758Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_qcy3FJ","project":"veritas-kanban","status":"todo","previousStatus":"done","id":"evt_-KkUZ32cY-cL","timestamp":"2026-01-28T02:34:56.532Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_qcy3FJ","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_Xad2QSppps2V","timestamp":"2026-01-28T02:36:31.643Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_qcy3FJ","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_Ic_dBBsAdxN2","timestamp":"2026-01-28T02:41:20.724Z"}
|
||||
{"type":"task.created","taskId":"task_20260128_2AhuSW","project":"veritas-kanban","status":"todo","id":"evt_ZSCSpCCeSO-x","timestamp":"2026-01-28T02:47:14.432Z"}
|
||||
{"type":"task.status_changed","taskId":"task_20260128_2AhuSW","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_KgJY6-MonU7W","timestamp":"2026-01-28T02:47:18.135Z"}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,38 @@
|
|||
[
|
||||
{
|
||||
"id": "activity_1769568438135_urtvgud9g",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_2AhuSW",
|
||||
"taskTitle": "US-910: Help context windows + keyboard shortcut fix",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "in-progress"
|
||||
},
|
||||
"timestamp": "2026-01-28T02:47:18.135Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769568434433_yi6801i5k",
|
||||
"type": "task_created",
|
||||
"taskId": "task_20260128_2AhuSW",
|
||||
"taskTitle": "US-910: Help context windows + keyboard shortcut fix",
|
||||
"details": {
|
||||
"type": "code",
|
||||
"priority": "high",
|
||||
"project": "veritas-kanban"
|
||||
},
|
||||
"timestamp": "2026-01-28T02:47:14.433Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769568080724_pbup1xy0d",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260128_qcy3FJ",
|
||||
"taskTitle": "US-907: Apply template to existing task",
|
||||
"details": {
|
||||
"from": "in-progress",
|
||||
"status": "done"
|
||||
},
|
||||
"timestamp": "2026-01-28T02:41:20.724Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769568075614_5u6dejgb9",
|
||||
"type": "task_updated",
|
||||
|
|
@ -942,41 +976,5 @@
|
|||
"project": "Sprint 8"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:57:55.527Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769450275474_mprychh2p",
|
||||
"type": "task_created",
|
||||
"taskId": "task_20260126_pSMY_Y",
|
||||
"taskTitle": "US-807: Dashboard Placement",
|
||||
"details": {
|
||||
"type": "code",
|
||||
"priority": "medium",
|
||||
"project": "Sprint 8"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:57:55.474Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769450275420_csokqg065",
|
||||
"type": "task_created",
|
||||
"taskId": "task_20260126_caioxm",
|
||||
"taskTitle": "US-806: Dashboard Component",
|
||||
"details": {
|
||||
"type": "code",
|
||||
"priority": "high",
|
||||
"project": "Sprint 8"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:57:55.420Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769450275365_4akuovlq5",
|
||||
"type": "task_created",
|
||||
"taskId": "task_20260126_HCSqiT",
|
||||
"taskTitle": "US-805: Metrics API",
|
||||
"details": {
|
||||
"type": "code",
|
||||
"priority": "high",
|
||||
"project": "Sprint 8"
|
||||
},
|
||||
"timestamp": "2026-01-26T17:57:55.365Z"
|
||||
}
|
||||
]
|
||||
|
|
@ -41,7 +41,7 @@ import {
|
|||
useDeleteTemplate,
|
||||
type TaskTemplate,
|
||||
} from '@/hooks/useTemplates';
|
||||
import { Plus, Trash2, Check, X, Loader2, FolderGit2, Bot, Star, FileText, Download, Upload } from 'lucide-react';
|
||||
import { Plus, Trash2, Check, X, Loader2, FolderGit2, Bot, Star, FileText, Download, Upload, HelpCircle, Info } from 'lucide-react';
|
||||
import type { RepoConfig, AgentConfig, AgentType, TaskType, TaskPriority } from '@veritas-kanban/shared';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TEMPLATE_CATEGORIES, getCategoryIcon, getCategoryLabel } from '@/lib/template-categories';
|
||||
|
|
@ -501,6 +501,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|||
const { data: templates, isLoading: templatesLoading } = useTemplates();
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [showAddTemplateForm, setShowAddTemplateForm] = useState(false);
|
||||
const [showTemplateHelp, setShowTemplateHelp] = useState(false);
|
||||
const updateAgents = useUpdateAgents();
|
||||
const setDefaultAgent = useSetDefaultAgent();
|
||||
const createTemplate = useCreateTemplate();
|
||||
|
|
@ -643,7 +644,17 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|||
{/* Templates Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Task Templates</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">Task Templates</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => setShowTemplateHelp(!showTemplateHelp)}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -676,6 +687,45 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
{showTemplateHelp && (
|
||||
<div className="p-3 rounded-md bg-muted/50 border border-muted-foreground/20 text-sm space-y-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-sm">Template Guide</p>
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1.5">
|
||||
<div>
|
||||
<strong className="text-foreground">Simple Templates:</strong>
|
||||
<p className="mt-0.5">Create templates with predefined task fields (type, priority, project) and description. Add subtask templates that will be created automatically.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong className="text-foreground">Categories:</strong>
|
||||
<p className="mt-0.5">Organize templates by category (Bug 🐛, Feature ✨, Sprint 🔄, etc.) for easier discovery.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong className="text-foreground">Variables:</strong>
|
||||
<p className="mt-0.5">Use <code className="px-1 py-0.5 rounded bg-muted">{'{{date}}'}</code>, <code className="px-1 py-0.5 rounded bg-muted">{'{{time}}'}</code>, <code className="px-1 py-0.5 rounded bg-muted">{'{{author}}'}</code>, or <code className="px-1 py-0.5 rounded bg-muted">{'{{project}}'}</code> for automatic values. Custom variables like <code className="px-1 py-0.5 rounded bg-muted">{'{{ticketId}}'}</code> will prompt for input.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong className="text-foreground">Blueprint Templates:</strong>
|
||||
<p className="mt-0.5">Advanced templates that create multiple tasks with dependencies. Perfect for multi-step workflows like sprint planning or feature launches.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong className="text-foreground">Import/Export:</strong>
|
||||
<p className="mt-0.5">Share templates as JSON files. Export your templates to back them up or share with others.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hidden file input for import */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|||
import { useCreateTask } from '@/hooks/useTasks';
|
||||
import { useTemplates, type TaskTemplate } from '@/hooks/useTemplates';
|
||||
import type { TaskType, TaskPriority, Subtask } from '@veritas-kanban/shared';
|
||||
import { FileText, X, Check, AlertCircle } from 'lucide-react';
|
||||
import { FileText, X, Check, AlertCircle, HelpCircle, Info } from 'lucide-react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import {
|
||||
interpolateVariables,
|
||||
|
|
@ -46,6 +46,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
const [customVars, setCustomVars] = useState<Record<string, string>>({});
|
||||
const [requiredCustomVars, setRequiredCustomVars] = useState<string[]>([]);
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
const createTask = useCreateTask();
|
||||
const { data: templates } = useTemplates();
|
||||
|
|
@ -246,10 +247,39 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
|
|||
{/* Template selector */}
|
||||
{templates && templates.length > 0 && (
|
||||
<div className="border-b pb-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<Label className="text-sm">Template</Label>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<Label className="text-sm">Template</Label>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => setShowHelp(!showHelp)}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
{showHelp && (
|
||||
<div className="mb-3 p-3 rounded-md bg-muted/50 border border-muted-foreground/20 text-sm space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-medium text-sm">Using Templates</p>
|
||||
<ul className="space-y-1 text-xs text-muted-foreground">
|
||||
<li>• <strong>Simple templates</strong> pre-fill task fields and can include subtasks</li>
|
||||
<li>• <strong>Variables</strong> like <code className="px-1 py-0.5 rounded bg-muted">{'{{date}}'}</code> or <code className="px-1 py-0.5 rounded bg-muted">{'{{author}}'}</code> are replaced when creating the task</li>
|
||||
<li>• <strong>Custom variables</strong> (e.g., <code className="px-1 py-0.5 rounded bg-muted">{'{{bugId}}'}</code>) prompt you for values</li>
|
||||
<li>• <strong>Blueprint templates</strong> create multiple linked tasks with dependencies</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Tabs value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="all" className="text-xs">All</TabsTrigger>
|
||||
|
|
|
|||
112
web/src/components/ui/toast.tsx
Normal file
112
web/src/components/ui/toast.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import * as React from 'react';
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'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 = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]: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',
|
||||
'bg-background border-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 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
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
};
|
||||
|
|
@ -1,4 +1,33 @@
|
|||
// Placeholder toaster - we'll implement this properly later
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from '@/components/ui/toast';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
export function Toaster() {
|
||||
return null;
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createContext, useContext, useCallback, useEffect, useState, useRef, type ReactNode } from 'react';
|
||||
import type { Task, TaskStatus } from '@veritas-kanban/shared';
|
||||
import { toast } from './useToast';
|
||||
|
||||
interface KeyboardContextValue {
|
||||
// Dialog triggers
|
||||
|
|
@ -90,7 +91,8 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
// Ignore if a dialog is open (except Escape)
|
||||
const dialogOpen = document.querySelector('[role="dialog"]');
|
||||
// Check for visible dialogs only (shadcn uses data-state attribute)
|
||||
const dialogOpen = document.querySelector('[role="dialog"][data-state="open"]');
|
||||
if (dialogOpen && e.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
|
@ -152,10 +154,16 @@ export function KeyboardProvider({ children }: { children: ReactNode }) {
|
|||
case '2':
|
||||
case '3':
|
||||
case '4':
|
||||
e.preventDefault();
|
||||
if (selectedTaskId && onMoveTaskRef.current) {
|
||||
e.preventDefault();
|
||||
const newStatus = STATUS_MAP[e.key];
|
||||
onMoveTaskRef.current(selectedTaskId, newStatus);
|
||||
} else {
|
||||
// Show toast when no task is selected
|
||||
toast({
|
||||
title: 'No task selected',
|
||||
description: 'Use j/k or arrow keys to select a task first',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
190
web/src/hooks/useToast.tsx
Normal file
190
web/src/hooks/useToast.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import * as React from 'react';
|
||||
|
||||
type ToastActionElement = React.ReactElement;
|
||||
|
||||
type ToastProps = {
|
||||
id: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: ToastActionElement;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const TOAST_LIMIT = 3;
|
||||
const TOAST_REMOVE_DELAY = 5000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_VALUE;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: typeof actionTypes.ADD_TOAST;
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: typeof actionTypes.UPDATE_TOAST;
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: typeof actionTypes.DISMISS_TOAST;
|
||||
toastId?: ToasterToast['id'];
|
||||
}
|
||||
| {
|
||||
type: typeof actionTypes.REMOVE_TOAST;
|
||||
toastId?: ToasterToast['id'];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
Loading…
Add table
Reference in a new issue