refactor: Create global TooltipProvider in app.tsx to eliminate duplication

- Add global TooltipProvider wrapper in app.tsx for entire application
- Remove 36 duplicate TooltipProvider instances across 20 UI component files
- Clean up imports by removing TooltipProvider from component imports
- Follow Radix UI best practices for TooltipProvider placement
- Reduce code by 62 lines while maintaining all tooltip functionality

Closes #694

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Shirone
2026-01-25 12:59:58 +01:00
parent 906f471521
commit 605d9658d9
20 changed files with 1132 additions and 1200 deletions

View File

@@ -1,6 +1,6 @@
import { useReactFlow, Panel } from '@xyflow/react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
ZoomIn,
ZoomOut,
@@ -30,109 +30,107 @@ export function GraphControls({
return (
<Panel position="bottom-left" className="flex flex-col gap-2">
<TooltipProvider delayDuration={200}>
<div
className="flex flex-col gap-1 p-1.5 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
>
{/* Zoom controls */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => zoomIn({ duration: 200 })}
>
<ZoomIn className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Zoom In</TooltipContent>
</Tooltip>
<div
className="flex flex-col gap-1 p-1.5 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
>
{/* Zoom controls */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => zoomIn({ duration: 200 })}
>
<ZoomIn className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Zoom In</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => zoomOut({ duration: 200 })}
>
<ZoomOut className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Zoom Out</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => zoomOut({ duration: 200 })}
>
<ZoomOut className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Zoom Out</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => fitView({ padding: 0.2, duration: 300 })}
>
<Maximize2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Fit View</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => fitView({ padding: 0.2, duration: 300 })}
>
<Maximize2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Fit View</TooltipContent>
</Tooltip>
<div className="h-px bg-border my-1" />
<div className="h-px bg-border my-1" />
{/* Layout controls */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-8 p-0',
layoutDirection === 'LR' && 'bg-brand-500/20 text-brand-500'
)}
onClick={() => onRunLayout('LR')}
>
<ArrowRight className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Horizontal Layout</TooltipContent>
</Tooltip>
{/* Layout controls */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-8 p-0',
layoutDirection === 'LR' && 'bg-brand-500/20 text-brand-500'
)}
onClick={() => onRunLayout('LR')}
>
<ArrowRight className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Horizontal Layout</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-8 p-0',
layoutDirection === 'TB' && 'bg-brand-500/20 text-brand-500'
)}
onClick={() => onRunLayout('TB')}
>
<ArrowDown className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Vertical Layout</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-8 p-0',
layoutDirection === 'TB' && 'bg-brand-500/20 text-brand-500'
)}
onClick={() => onRunLayout('TB')}
>
<ArrowDown className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">Vertical Layout</TooltipContent>
</Tooltip>
<div className="h-px bg-border my-1" />
<div className="h-px bg-border my-1" />
{/* Lock toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn('h-8 w-8 p-0', isLocked && 'bg-brand-500/20 text-brand-500')}
onClick={onToggleLock}
>
{isLocked ? <Lock className="w-4 h-4" /> : <Unlock className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent side="right">{isLocked ? 'Unlock Nodes' : 'Lock Nodes'}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
{/* Lock toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn('h-8 w-8 p-0', isLocked && 'bg-brand-500/20 text-brand-500')}
onClick={onToggleLock}
>
{isLocked ? <Lock className="w-4 h-4" /> : <Unlock className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent side="right">{isLocked ? 'Unlock Nodes' : 'Lock Nodes'}</TooltipContent>
</Tooltip>
</div>
</Panel>
);
}

View File

@@ -4,7 +4,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
Filter,
X,
@@ -115,248 +115,244 @@ export function GraphFilterControls({
return (
<Panel position="top-left" className="flex items-center gap-2">
<TooltipProvider delayDuration={200}>
<div
className="flex items-center gap-2 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
>
{/* Search Input */}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
type="text"
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
className="h-8 w-48 pl-8 pr-8 text-sm bg-background/50"
/>
{searchQuery && (
<button
onClick={() => onSearchQueryChange('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Divider */}
<div className="h-6 w-px bg-border" />
{/* Category Filter Dropdown */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 px-2 gap-1.5',
selectedCategories.length > 0 && 'bg-brand-500/20 text-brand-500'
)}
>
<Filter className="w-4 h-4" />
<span className="text-xs max-w-[100px] truncate">{categoryButtonLabel}</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Filter by Category</TooltipContent>
</Tooltip>
<PopoverContent align="start" className="w-56 p-2">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground px-2 py-1">
Categories
</div>
{/* Select All option */}
<div
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={handleSelectAllCategories}
>
<Checkbox
checked={
selectedCategories.length === availableCategories.length &&
availableCategories.length > 0
}
onCheckedChange={handleSelectAllCategories}
/>
<span className="text-sm font-medium">
{selectedCategories.length === availableCategories.length
? 'Deselect All'
: 'Select All'}
</span>
</div>
<div className="h-px bg-border" />
{/* Category list */}
<div className="max-h-48 overflow-y-auto space-y-0.5">
{availableCategories.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-2">
No categories available
</div>
) : (
availableCategories.map((category) => (
<div
key={category}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={() => handleCategoryToggle(category)}
>
<Checkbox
checked={selectedCategories.includes(category)}
onCheckedChange={() => handleCategoryToggle(category)}
/>
<span className="text-sm truncate">{category}</span>
</div>
))
)}
</div>
</div>
</PopoverContent>
</Popover>
{/* Status Filter Dropdown */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 px-2 gap-1.5',
selectedStatuses.length > 0 && 'bg-brand-500/20 text-brand-500'
)}
>
<CircleDot className="w-4 h-4" />
<span className="text-xs max-w-[120px] truncate">{statusButtonLabel}</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Filter by Status</TooltipContent>
</Tooltip>
<PopoverContent align="start" className="w-56 p-2">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground px-2 py-1">Status</div>
{/* Select All option */}
<div
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={handleSelectAllStatuses}
>
<Checkbox
checked={selectedStatuses.length === STATUS_FILTER_OPTIONS.length}
onCheckedChange={handleSelectAllStatuses}
/>
<span className="text-sm font-medium">
{selectedStatuses.length === STATUS_FILTER_OPTIONS.length
? 'Deselect All'
: 'Select All'}
</span>
</div>
<div className="h-px bg-border" />
{/* Status list */}
<div className="space-y-0.5">
{STATUS_FILTER_OPTIONS.map((status) => {
const config = statusDisplayConfig[status];
const StatusIcon = config.icon;
return (
<div
key={status}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={() => handleStatusToggle(status)}
>
<Checkbox
checked={selectedStatuses.includes(status)}
onCheckedChange={() => handleStatusToggle(status)}
/>
<StatusIcon className={cn('w-3.5 h-3.5', config.colorClass)} />
<span className="text-sm">{config.label}</span>
</div>
);
})}
</div>
</div>
</PopoverContent>
</Popover>
{/* Divider */}
<div className="h-6 w-px bg-border" />
{/* Positive/Negative Filter Toggle */}
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<button
onClick={() => onNegativeFilterChange(!isNegativeFilter)}
aria-label={
isNegativeFilter
? 'Switch to show matching nodes'
: 'Switch to hide matching nodes'
}
aria-pressed={isNegativeFilter}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors',
isNegativeFilter
? 'bg-orange-500/20 text-orange-500'
: 'hover:bg-accent text-muted-foreground hover:text-foreground'
)}
>
{isNegativeFilter ? (
<>
<EyeOff className="w-3.5 h-3.5" />
<span>Hide</span>
</>
) : (
<>
<Eye className="w-3.5 h-3.5" />
<span>Show</span>
</>
)}
</button>
<Switch
checked={isNegativeFilter}
onCheckedChange={onNegativeFilterChange}
aria-label="Toggle between show and hide filter modes"
className="h-5 w-9 data-[state=checked]:bg-orange-500"
/>
</div>
</TooltipTrigger>
<TooltipContent>
{isNegativeFilter
? 'Negative filter: Highlighting non-matching nodes'
: 'Positive filter: Highlighting matching nodes'}
</TooltipContent>
</Tooltip>
{/* Clear Filters Button - only show when filters are active */}
{hasActiveFilter && (
<>
<div className="h-6 w-px bg-border" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
onClick={onClearFilters}
aria-label="Clear all filters"
>
<X className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Clear All Filters</TooltipContent>
</Tooltip>
</>
<div
className="flex items-center gap-2 p-2 rounded-lg backdrop-blur-sm border border-border shadow-lg text-popover-foreground"
style={{ backgroundColor: 'color-mix(in oklch, var(--popover) 90%, transparent)' }}
>
{/* Search Input */}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
<Input
type="text"
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
className="h-8 w-48 pl-8 pr-8 text-sm bg-background/50"
/>
{searchQuery && (
<button
onClick={() => onSearchQueryChange('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</TooltipProvider>
{/* Divider */}
<div className="h-6 w-px bg-border" />
{/* Category Filter Dropdown */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 px-2 gap-1.5',
selectedCategories.length > 0 && 'bg-brand-500/20 text-brand-500'
)}
>
<Filter className="w-4 h-4" />
<span className="text-xs max-w-[100px] truncate">{categoryButtonLabel}</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Filter by Category</TooltipContent>
</Tooltip>
<PopoverContent align="start" className="w-56 p-2">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground px-2 py-1">Categories</div>
{/* Select All option */}
<div
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={handleSelectAllCategories}
>
<Checkbox
checked={
selectedCategories.length === availableCategories.length &&
availableCategories.length > 0
}
onCheckedChange={handleSelectAllCategories}
/>
<span className="text-sm font-medium">
{selectedCategories.length === availableCategories.length
? 'Deselect All'
: 'Select All'}
</span>
</div>
<div className="h-px bg-border" />
{/* Category list */}
<div className="max-h-48 overflow-y-auto space-y-0.5">
{availableCategories.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-2">
No categories available
</div>
) : (
availableCategories.map((category) => (
<div
key={category}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={() => handleCategoryToggle(category)}
>
<Checkbox
checked={selectedCategories.includes(category)}
onCheckedChange={() => handleCategoryToggle(category)}
/>
<span className="text-sm truncate">{category}</span>
</div>
))
)}
</div>
</div>
</PopoverContent>
</Popover>
{/* Status Filter Dropdown */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 px-2 gap-1.5',
selectedStatuses.length > 0 && 'bg-brand-500/20 text-brand-500'
)}
>
<CircleDot className="w-4 h-4" />
<span className="text-xs max-w-[120px] truncate">{statusButtonLabel}</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Filter by Status</TooltipContent>
</Tooltip>
<PopoverContent align="start" className="w-56 p-2">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground px-2 py-1">Status</div>
{/* Select All option */}
<div
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={handleSelectAllStatuses}
>
<Checkbox
checked={selectedStatuses.length === STATUS_FILTER_OPTIONS.length}
onCheckedChange={handleSelectAllStatuses}
/>
<span className="text-sm font-medium">
{selectedStatuses.length === STATUS_FILTER_OPTIONS.length
? 'Deselect All'
: 'Select All'}
</span>
</div>
<div className="h-px bg-border" />
{/* Status list */}
<div className="space-y-0.5">
{STATUS_FILTER_OPTIONS.map((status) => {
const config = statusDisplayConfig[status];
const StatusIcon = config.icon;
return (
<div
key={status}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={() => handleStatusToggle(status)}
>
<Checkbox
checked={selectedStatuses.includes(status)}
onCheckedChange={() => handleStatusToggle(status)}
/>
<StatusIcon className={cn('w-3.5 h-3.5', config.colorClass)} />
<span className="text-sm">{config.label}</span>
</div>
);
})}
</div>
</div>
</PopoverContent>
</Popover>
{/* Divider */}
<div className="h-6 w-px bg-border" />
{/* Positive/Negative Filter Toggle */}
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<button
onClick={() => onNegativeFilterChange(!isNegativeFilter)}
aria-label={
isNegativeFilter
? 'Switch to show matching nodes'
: 'Switch to hide matching nodes'
}
aria-pressed={isNegativeFilter}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors',
isNegativeFilter
? 'bg-orange-500/20 text-orange-500'
: 'hover:bg-accent text-muted-foreground hover:text-foreground'
)}
>
{isNegativeFilter ? (
<>
<EyeOff className="w-3.5 h-3.5" />
<span>Hide</span>
</>
) : (
<>
<Eye className="w-3.5 h-3.5" />
<span>Show</span>
</>
)}
</button>
<Switch
checked={isNegativeFilter}
onCheckedChange={onNegativeFilterChange}
aria-label="Toggle between show and hide filter modes"
className="h-5 w-9 data-[state=checked]:bg-orange-500"
/>
</div>
</TooltipTrigger>
<TooltipContent>
{isNegativeFilter
? 'Negative filter: Highlighting non-matching nodes'
: 'Positive filter: Highlighting matching nodes'}
</TooltipContent>
</Tooltip>
{/* Clear Filters Button - only show when filters are active */}
{hasActiveFilter && (
<>
<div className="h-6 w-px bg-border" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
onClick={onClearFilters}
aria-label="Clear all filters"
>
<X className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Clear All Filters</TooltipContent>
</Tooltip>
</>
)}
</div>
</Panel>
);
}

View File

@@ -26,7 +26,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
type TaskNodeProps = NodeProps & {
data: TaskNodeData;