mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +00:00
feat(design): animated design system foundation with animate-ui and motion
Install motion + animate-ui, overhaul design tokens with brand cyan accent, and replace CSS keyframe animations in Dialog, Tabs, Switch, and Tooltip with spring-physics and blur-fade transitions via animate-ui Radix primitives.
This commit is contained in:
@@ -162,7 +162,7 @@ export default function EditorLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
// Notification WS push — load history once on mount, then receive live updates
|
||||
// Notification WS push - load history once on mount, then receive live updates
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
|
||||
@@ -300,7 +300,7 @@ export default function EditorLayout() {
|
||||
}
|
||||
|
||||
// Rate is derived from rawBytesRef which is never cleared on flush,
|
||||
// so the delta is always accurate — no stale-closure risk.
|
||||
// so the delta is always accurate - no stale-closure risk.
|
||||
const prevRaw = rawBytesRef.current[container.Id];
|
||||
const rxRate = prevRaw ? Math.max(0, currentRx - prevRaw.lastRx) : 0;
|
||||
const txRate = prevRaw ? Math.max(0, currentTx - prevRaw.lastTx) : 0;
|
||||
@@ -308,7 +308,7 @@ export default function EditorLayout() {
|
||||
|
||||
const netIO = `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`;
|
||||
|
||||
// Write into the buffer ref only — zero re-render cost.
|
||||
// Write into the buffer ref only - zero re-render cost.
|
||||
pendingStatsRef.current[container.Id] = {
|
||||
cpu: cpuPercent + '%',
|
||||
ram: ramUsage,
|
||||
@@ -770,7 +770,7 @@ export default function EditorLayout() {
|
||||
<SelectItem key={node.id} value={node.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${node.status === 'online' ? 'bg-green-500' :
|
||||
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||||
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||||
}`} />
|
||||
{node.name}
|
||||
</div>
|
||||
@@ -880,7 +880,7 @@ export default function EditorLayout() {
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Top Header Bar */}
|
||||
<div className="h-16 flex items-center justify-between px-6 border-b border-border gap-4">
|
||||
{/* Node Context Pill — visible only when a remote node is active */}
|
||||
{/* Node Context Pill - visible only when a remote node is active */}
|
||||
<div className="flex-shrink-0">
|
||||
{activeNode?.type === 'remote' ? (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm font-medium">
|
||||
@@ -895,156 +895,156 @@ export default function EditorLayout() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Home Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => {
|
||||
setSelectedFile(null);
|
||||
setContent('');
|
||||
setOriginalContent('');
|
||||
setEnvContent('');
|
||||
setOriginalEnvContent('');
|
||||
setEnvFiles([]);
|
||||
setSelectedEnvFile('');
|
||||
setEnvExists(false);
|
||||
setContainers([]);
|
||||
setIsEditing(false);
|
||||
setActiveView('dashboard');
|
||||
}}
|
||||
title="Go to Home Dashboard"
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
Home
|
||||
</Button>
|
||||
{/* Console Toggle */}
|
||||
<Button
|
||||
variant={activeView === 'host-console' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView(activeView === 'host-console' ? (selectedFile ? 'editor' : 'dashboard') : 'host-console')}
|
||||
>
|
||||
<Terminal className="w-4 h-4 mr-2" />
|
||||
Console
|
||||
</Button>
|
||||
{/* Resources Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView('resources')}
|
||||
title="System Resources"
|
||||
>
|
||||
<HardDrive className="w-4 h-4 mr-2" />
|
||||
Resources
|
||||
</Button>
|
||||
{/* App Store Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView('templates')}
|
||||
title="App Store"
|
||||
>
|
||||
<CloudDownload className="w-4 h-4 mr-2" />
|
||||
App Store
|
||||
</Button>
|
||||
{/* Global Observability Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView('global-observability')}
|
||||
title="Global Logs"
|
||||
>
|
||||
<Activity className="w-4 h-4 mr-2" />
|
||||
Logs
|
||||
</Button>
|
||||
{/* Home Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => {
|
||||
setSelectedFile(null);
|
||||
setContent('');
|
||||
setOriginalContent('');
|
||||
setEnvContent('');
|
||||
setOriginalEnvContent('');
|
||||
setEnvFiles([]);
|
||||
setSelectedEnvFile('');
|
||||
setEnvExists(false);
|
||||
setContainers([]);
|
||||
setIsEditing(false);
|
||||
setActiveView('dashboard');
|
||||
}}
|
||||
title="Go to Home Dashboard"
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
Home
|
||||
</Button>
|
||||
{/* Console Toggle */}
|
||||
<Button
|
||||
variant={activeView === 'host-console' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView(activeView === 'host-console' ? (selectedFile ? 'editor' : 'dashboard') : 'host-console')}
|
||||
>
|
||||
<Terminal className="w-4 h-4 mr-2" />
|
||||
Console
|
||||
</Button>
|
||||
{/* Resources Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView('resources')}
|
||||
title="System Resources"
|
||||
>
|
||||
<HardDrive className="w-4 h-4 mr-2" />
|
||||
Resources
|
||||
</Button>
|
||||
{/* App Store Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView('templates')}
|
||||
title="App Store"
|
||||
>
|
||||
<CloudDownload className="w-4 h-4 mr-2" />
|
||||
App Store
|
||||
</Button>
|
||||
{/* Global Observability Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView('global-observability')}
|
||||
title="Global Logs"
|
||||
>
|
||||
<Activity className="w-4 h-4 mr-2" />
|
||||
Logs
|
||||
</Button>
|
||||
|
||||
{/* Settings Modal Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setSettingsModalOpen(true)}
|
||||
title="Notification Settings"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
{/* Settings Modal Toggle */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setSettingsModalOpen(true)}
|
||||
title="Notification Settings"
|
||||
>
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
|
||||
{/* Notifications Popover */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="rounded-lg relative" title="Notifications">
|
||||
<Bell className="w-4 h-4" />
|
||||
{notifications.filter(n => !n.is_read).length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h4 className="font-semibold">Notifications</h4>
|
||||
<div className="flex gap-2">
|
||||
{/* Notifications Popover */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="rounded-lg relative" title="Notifications">
|
||||
<Bell className="w-4 h-4" />
|
||||
{notifications.filter(n => !n.is_read).length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={markAllRead} className="h-auto p-0 text-xs">
|
||||
Mark all as read
|
||||
</Button>
|
||||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
|
||||
</span>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={clearAllNotifications} className="h-auto p-0 text-xs text-muted-foreground hover:text-destructive transition-colors">
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-80">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground text-center">No notifications</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{notifications.map((notif: any) => (
|
||||
<div key={notif.id} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'} relative group`}>
|
||||
<div className="flex items-center gap-2 mb-1 pr-6">
|
||||
<Badge variant={notif.level === 'error' ? 'destructive' : notif.level === 'warning' ? 'secondary' : 'default'} className="text-[10px] uppercase">
|
||||
{notif.level}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{new Date(notif.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium pr-6">{notif.message}</p>
|
||||
|
||||
{/* Delete individual notification button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteNotification(notif.id);
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h4 className="font-semibold">Notifications</h4>
|
||||
<div className="flex gap-2">
|
||||
{notifications.filter(n => !n.is_read).length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={markAllRead} className="h-auto p-0 text-xs">
|
||||
Mark all as read
|
||||
</Button>
|
||||
)}
|
||||
{notifications.length > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={clearAllNotifications} className="h-auto p-0 text-xs text-muted-foreground hover:text-destructive transition-colors">
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<ScrollArea className="h-80">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground text-center">No notifications</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{notifications.map((notif: any) => (
|
||||
<div key={notif.id} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'} relative group`}>
|
||||
<div className="flex items-center gap-2 mb-1 pr-6">
|
||||
<Badge variant={notif.level === 'error' ? 'destructive' : notif.level === 'warning' ? 'secondary' : 'default'} className="text-[10px] uppercase">
|
||||
{notif.level}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{new Date(notif.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-medium pr-6">{notif.message}</p>
|
||||
|
||||
{/* Delete individual notification button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteNotification(notif.id);
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>{/* end right-side buttons */}
|
||||
</div>
|
||||
|
||||
{/* Main Workspace */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div key={activeView} className="flex-1 overflow-y-auto p-6 animate-fade-up">
|
||||
{activeView === 'templates' ? (
|
||||
<AppStoreView onDeploySuccess={(stackName) => { refreshStacks(); loadFile(stackName); }} />
|
||||
) : activeView === 'resources' ? (
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
|
||||
<span>Host Console</span>
|
||||
{activeNode && (
|
||||
<span className="text-muted-foreground font-normal text-sm">
|
||||
— {activeNode.name}
|
||||
- {activeNode.name}
|
||||
</span>
|
||||
)}
|
||||
{stackName && (
|
||||
|
||||
@@ -247,13 +247,13 @@ export function NodeManager() {
|
||||
<SelectItem value="local">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
Local — Docker socket on this machine
|
||||
Local - Docker socket on this machine
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="remote">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
Remote — another Sencho instance
|
||||
Remote - another Sencho instance
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
@@ -191,7 +191,7 @@ export default function ResourcesView() {
|
||||
<HardDrive className="w-6 h-6" />
|
||||
<h1 className="text-2xl font-bold">Resources Hub</h1>
|
||||
{activeNode?.type === 'remote' && (
|
||||
<span className="text-sm font-normal text-muted-foreground">— {activeNode.name}</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">- {activeNode.name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
webhook: { type: 'webhook', url: '', enabled: false },
|
||||
});
|
||||
|
||||
// Settings state — all user-configurable keys (no auth keys)
|
||||
// Settings state - all user-configurable keys (no auth keys)
|
||||
const [settings, setSettings] = useState<PatchableSettings>({ ...DEFAULT_SETTINGS });
|
||||
|
||||
// Track server state to detect unsaved changes without causing re-renders
|
||||
@@ -100,17 +100,17 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
|
||||
// Unsaved changes indicators per section (compared against server ref)
|
||||
const hasSystemChanges =
|
||||
settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit ||
|
||||
settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit ||
|
||||
settings.host_disk_limit !== serverSettingsRef.current.host_disk_limit ||
|
||||
settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit ||
|
||||
settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit ||
|
||||
settings.host_disk_limit !== serverSettingsRef.current.host_disk_limit ||
|
||||
settings.docker_janitor_gb !== serverSettingsRef.current.docker_janitor_gb ||
|
||||
settings.global_crash !== serverSettingsRef.current.global_crash;
|
||||
settings.global_crash !== serverSettingsRef.current.global_crash;
|
||||
|
||||
const hasDeveloperChanges =
|
||||
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
|
||||
settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh ||
|
||||
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
|
||||
settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh ||
|
||||
settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours ||
|
||||
settings.log_retention_days !== serverSettingsRef.current.log_retention_days;
|
||||
settings.log_retention_days !== serverSettingsRef.current.log_retention_days;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -140,7 +140,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
try {
|
||||
// Fetch per-node settings from the active node (system limits etc.)
|
||||
const nodeRes = await apiFetch('/settings');
|
||||
// Always fetch developer/UI preferences from local — these control
|
||||
// Always fetch developer/UI preferences from local - these control
|
||||
// this Sencho instance's behaviour and must never be proxied to remote
|
||||
const localRes = isRemote ? await apiFetch('/settings', { localOnly: true }) : nodeRes;
|
||||
|
||||
@@ -151,17 +151,17 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
|
||||
const safe: PatchableSettings = {
|
||||
// Per-node: read from active node
|
||||
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
|
||||
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
|
||||
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
|
||||
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
|
||||
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
|
||||
template_registry_url: nodeData.template_registry_url ?? '',
|
||||
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
|
||||
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
|
||||
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
|
||||
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
|
||||
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
|
||||
template_registry_url: nodeData.template_registry_url ?? '',
|
||||
// Local-only: always read from local node
|
||||
global_logs_refresh: (localData.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh,
|
||||
developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
|
||||
global_logs_refresh: (localData.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh,
|
||||
developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
|
||||
metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
|
||||
log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
|
||||
log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
|
||||
};
|
||||
setSettings(safe);
|
||||
serverSettingsRef.current = { ...safe };
|
||||
@@ -201,22 +201,22 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
|
||||
const saveSystemSettings = async () => {
|
||||
const ok = await patchSettings({
|
||||
host_cpu_limit: settings.host_cpu_limit,
|
||||
host_ram_limit: settings.host_ram_limit,
|
||||
host_disk_limit: settings.host_disk_limit,
|
||||
host_cpu_limit: settings.host_cpu_limit,
|
||||
host_ram_limit: settings.host_ram_limit,
|
||||
host_disk_limit: settings.host_disk_limit,
|
||||
docker_janitor_gb: settings.docker_janitor_gb,
|
||||
global_crash: settings.global_crash,
|
||||
global_crash: settings.global_crash,
|
||||
}, setIsSavingSystem);
|
||||
if (ok) toast.success('System limits saved.');
|
||||
};
|
||||
|
||||
const saveDeveloperSettings = async () => {
|
||||
// Developer/UI preferences are local-only — never proxy to remote node
|
||||
// Developer/UI preferences are local-only - never proxy to remote node
|
||||
const ok = await patchSettings({
|
||||
developer_mode: settings.developer_mode,
|
||||
global_logs_refresh: settings.global_logs_refresh,
|
||||
developer_mode: settings.developer_mode,
|
||||
global_logs_refresh: settings.global_logs_refresh,
|
||||
metrics_retention_hours: settings.metrics_retention_hours,
|
||||
log_retention_days: settings.log_retention_days,
|
||||
log_retention_days: settings.log_retention_days,
|
||||
}, setIsSavingDeveloper, true);
|
||||
if (ok) toast.success('Developer settings saved.');
|
||||
};
|
||||
@@ -670,7 +670,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{settings.developer_mode === '1' && (
|
||||
<p className="text-xs text-amber-500">SSE streaming is active — polling rate is overridden.</p>
|
||||
<p className="text-xs text-amber-500">SSE streaming is active - polling rate is overridden.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -750,7 +750,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base">Default Registry</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
LinuxServer.io — <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
|
||||
LinuxServer.io - <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Used when no custom registry is set.</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion, isMotionComponent, type HTMLMotionProps } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type AnyProps = Record<string, unknown>;
|
||||
|
||||
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<
|
||||
HTMLMotionProps<keyof HTMLElementTagNameMap>,
|
||||
'ref'
|
||||
> & { ref?: React.Ref<T> };
|
||||
|
||||
type WithAsChild<Base extends object> =
|
||||
| (Base & { asChild: true; children: React.ReactElement })
|
||||
| (Base & { asChild?: false | undefined });
|
||||
|
||||
type SlotProps<T extends HTMLElement = HTMLElement> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
children?: any;
|
||||
} & DOMMotionProps<T>;
|
||||
|
||||
function mergeRefs<T>(
|
||||
...refs: (React.Ref<T> | undefined)[]
|
||||
): React.RefCallback<T> {
|
||||
return (node) => {
|
||||
refs.forEach((ref) => {
|
||||
if (!ref) return;
|
||||
if (typeof ref === 'function') {
|
||||
ref(node);
|
||||
} else {
|
||||
(ref as React.RefObject<T | null>).current = node;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function mergeProps<T extends HTMLElement>(
|
||||
childProps: AnyProps,
|
||||
slotProps: DOMMotionProps<T>,
|
||||
): AnyProps {
|
||||
const merged: AnyProps = { ...childProps, ...slotProps };
|
||||
|
||||
if (childProps.className || slotProps.className) {
|
||||
merged.className = cn(
|
||||
childProps.className as string,
|
||||
slotProps.className as string,
|
||||
);
|
||||
}
|
||||
|
||||
if (childProps.style || slotProps.style) {
|
||||
merged.style = {
|
||||
...(childProps.style as React.CSSProperties),
|
||||
...(slotProps.style as React.CSSProperties),
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function Slot<T extends HTMLElement = HTMLElement>({
|
||||
children,
|
||||
ref,
|
||||
...props
|
||||
}: SlotProps<T>) {
|
||||
const isAlreadyMotion =
|
||||
typeof children.type === 'object' &&
|
||||
children.type !== null &&
|
||||
isMotionComponent(children.type);
|
||||
|
||||
const Base = React.useMemo(
|
||||
() =>
|
||||
isAlreadyMotion
|
||||
? (children.type as React.ElementType)
|
||||
: motion.create(children.type as React.ElementType),
|
||||
[isAlreadyMotion, children.type],
|
||||
);
|
||||
|
||||
if (!React.isValidElement(children)) return null;
|
||||
|
||||
const { ref: childRef, ...childProps } = children.props as AnyProps;
|
||||
|
||||
const mergedProps = mergeProps(childProps, props);
|
||||
|
||||
return (
|
||||
<Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Slot,
|
||||
type SlotProps,
|
||||
type WithAsChild,
|
||||
type DOMMotionProps,
|
||||
type AnyProps,
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
motion,
|
||||
type HTMLMotionProps,
|
||||
type LegacyAnimationControls,
|
||||
type TargetAndTransition,
|
||||
type Transition,
|
||||
} from 'motion/react';
|
||||
|
||||
import { useAutoHeight } from '@/hooks/use-auto-height';
|
||||
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
|
||||
|
||||
type AutoHeightProps = WithAsChild<
|
||||
{
|
||||
children: React.ReactNode;
|
||||
deps?: React.DependencyList;
|
||||
animate?: TargetAndTransition | LegacyAnimationControls;
|
||||
transition?: Transition;
|
||||
} & Omit<HTMLMotionProps<'div'>, 'animate'>
|
||||
>;
|
||||
|
||||
function AutoHeight({
|
||||
children,
|
||||
deps = [],
|
||||
transition = {
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
bounce: 0,
|
||||
restDelta: 0.01,
|
||||
},
|
||||
style,
|
||||
animate,
|
||||
asChild = false,
|
||||
...props
|
||||
}: AutoHeightProps) {
|
||||
const { ref, height } = useAutoHeight<HTMLDivElement>(deps);
|
||||
|
||||
const Comp = asChild ? Slot : motion.div;
|
||||
|
||||
return (
|
||||
<Comp
|
||||
style={{ overflow: 'hidden', ...style }}
|
||||
animate={{ height, ...animate }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
>
|
||||
<div ref={ref}>{children}</div>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
export { AutoHeight, type AutoHeightProps };
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import {
|
||||
useIsInView,
|
||||
type UseIsInViewOptions,
|
||||
} from '@/hooks/use-is-in-view';
|
||||
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
|
||||
|
||||
type FadeProps = WithAsChild<
|
||||
{
|
||||
children?: React.ReactNode;
|
||||
delay?: number;
|
||||
initialOpacity?: number;
|
||||
opacity?: number;
|
||||
ref?: React.Ref<HTMLElement>;
|
||||
} & UseIsInViewOptions &
|
||||
HTMLMotionProps<'div'>
|
||||
>;
|
||||
|
||||
function Fade({
|
||||
ref,
|
||||
transition = { type: 'spring', stiffness: 200, damping: 20 },
|
||||
delay = 0,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
initialOpacity = 0,
|
||||
opacity = 1,
|
||||
asChild = false,
|
||||
...props
|
||||
}: FadeProps) {
|
||||
const { ref: localRef, isInView } = useIsInView(
|
||||
ref as React.Ref<HTMLElement>,
|
||||
{
|
||||
inView,
|
||||
inViewOnce,
|
||||
inViewMargin,
|
||||
},
|
||||
);
|
||||
|
||||
const Component = asChild ? Slot : motion.div;
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={localRef as React.Ref<HTMLDivElement>}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
exit="hidden"
|
||||
variants={{
|
||||
hidden: { opacity: initialOpacity },
|
||||
visible: { opacity },
|
||||
}}
|
||||
transition={{
|
||||
...transition,
|
||||
delay: (transition?.delay ?? 0) + delay / 1000,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type FadeListProps = Omit<FadeProps, 'children'> & {
|
||||
children: React.ReactElement | React.ReactElement[];
|
||||
holdDelay?: number;
|
||||
};
|
||||
|
||||
function Fades({
|
||||
children,
|
||||
delay = 0,
|
||||
holdDelay = 0,
|
||||
...props
|
||||
}: FadeListProps) {
|
||||
const array = React.Children.toArray(children) as React.ReactElement[];
|
||||
|
||||
return (
|
||||
<>
|
||||
{array.map((child, index) => (
|
||||
<Fade
|
||||
key={child.key ?? index}
|
||||
delay={delay + index * holdDelay}
|
||||
{...props}
|
||||
>
|
||||
{child}
|
||||
</Fade>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { Fade, Fades, type FadeProps, type FadeListProps };
|
||||
@@ -0,0 +1,640 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AnimatePresence, motion, type Transition } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type HighlightMode = 'children' | 'parent';
|
||||
|
||||
type Bounds = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const DEFAULT_BOUNDS_OFFSET: Bounds = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
type HighlightContextType<T extends string> = {
|
||||
as?: keyof HTMLElementTagNameMap;
|
||||
mode: HighlightMode;
|
||||
activeValue: T | null;
|
||||
setActiveValue: (value: T | null) => void;
|
||||
setBounds: (bounds: DOMRect) => void;
|
||||
clearBounds: () => void;
|
||||
id: string;
|
||||
hover: boolean;
|
||||
click: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
activeClassName?: string;
|
||||
setActiveClassName: (className: string) => void;
|
||||
transition?: Transition;
|
||||
disabled?: boolean;
|
||||
enabled?: boolean;
|
||||
exitDelay?: number;
|
||||
forceUpdateBounds?: boolean;
|
||||
};
|
||||
|
||||
const HighlightContext = React.createContext<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
HighlightContextType<any> | undefined
|
||||
>(undefined);
|
||||
|
||||
function useHighlight<T extends string>(): HighlightContextType<T> {
|
||||
const context = React.useContext(HighlightContext);
|
||||
if (!context) {
|
||||
throw new Error('useHighlight must be used within a HighlightProvider');
|
||||
}
|
||||
return context as unknown as HighlightContextType<T>;
|
||||
}
|
||||
|
||||
type BaseHighlightProps<T extends React.ElementType = 'div'> = {
|
||||
as?: T;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
mode?: HighlightMode;
|
||||
value?: string | null;
|
||||
defaultValue?: string | null;
|
||||
onValueChange?: (value: string | null) => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
transition?: Transition;
|
||||
hover?: boolean;
|
||||
click?: boolean;
|
||||
disabled?: boolean;
|
||||
enabled?: boolean;
|
||||
exitDelay?: number;
|
||||
};
|
||||
|
||||
type ParentModeHighlightProps = {
|
||||
boundsOffset?: Partial<Bounds>;
|
||||
containerClassName?: string;
|
||||
forceUpdateBounds?: boolean;
|
||||
};
|
||||
|
||||
type ControlledParentModeHighlightProps<T extends React.ElementType = 'div'> =
|
||||
BaseHighlightProps<T> &
|
||||
ParentModeHighlightProps & {
|
||||
mode: 'parent';
|
||||
controlledItems: true;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type ControlledChildrenModeHighlightProps<T extends React.ElementType = 'div'> =
|
||||
BaseHighlightProps<T> & {
|
||||
mode?: 'children' | undefined;
|
||||
controlledItems: true;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type UncontrolledParentModeHighlightProps<T extends React.ElementType = 'div'> =
|
||||
BaseHighlightProps<T> &
|
||||
ParentModeHighlightProps & {
|
||||
mode: 'parent';
|
||||
controlledItems?: false;
|
||||
itemsClassName?: string;
|
||||
children: React.ReactElement | React.ReactElement[];
|
||||
};
|
||||
|
||||
type UncontrolledChildrenModeHighlightProps<
|
||||
T extends React.ElementType = 'div',
|
||||
> = BaseHighlightProps<T> & {
|
||||
mode?: 'children';
|
||||
controlledItems?: false;
|
||||
itemsClassName?: string;
|
||||
children: React.ReactElement | React.ReactElement[];
|
||||
};
|
||||
|
||||
type HighlightProps<T extends React.ElementType = 'div'> =
|
||||
| ControlledParentModeHighlightProps<T>
|
||||
| ControlledChildrenModeHighlightProps<T>
|
||||
| UncontrolledParentModeHighlightProps<T>
|
||||
| UncontrolledChildrenModeHighlightProps<T>;
|
||||
|
||||
function Highlight<T extends React.ElementType = 'div'>({
|
||||
ref,
|
||||
...props
|
||||
}: HighlightProps<T>) {
|
||||
const {
|
||||
as: Component = 'div',
|
||||
children,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
className,
|
||||
style,
|
||||
transition = { type: 'spring', stiffness: 350, damping: 35 },
|
||||
hover = false,
|
||||
click = true,
|
||||
enabled = true,
|
||||
controlledItems,
|
||||
disabled = false,
|
||||
exitDelay = 200,
|
||||
mode = 'children',
|
||||
} = props;
|
||||
|
||||
const localRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
|
||||
|
||||
const propsBoundsOffset = (props as ParentModeHighlightProps)?.boundsOffset;
|
||||
const boundsOffset = propsBoundsOffset ?? DEFAULT_BOUNDS_OFFSET;
|
||||
const boundsOffsetTop = boundsOffset.top ?? 0;
|
||||
const boundsOffsetLeft = boundsOffset.left ?? 0;
|
||||
const boundsOffsetWidth = boundsOffset.width ?? 0;
|
||||
const boundsOffsetHeight = boundsOffset.height ?? 0;
|
||||
|
||||
const boundsOffsetRef = React.useRef({
|
||||
top: boundsOffsetTop,
|
||||
left: boundsOffsetLeft,
|
||||
width: boundsOffsetWidth,
|
||||
height: boundsOffsetHeight,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
boundsOffsetRef.current = {
|
||||
top: boundsOffsetTop,
|
||||
left: boundsOffsetLeft,
|
||||
width: boundsOffsetWidth,
|
||||
height: boundsOffsetHeight,
|
||||
};
|
||||
}, [
|
||||
boundsOffsetTop,
|
||||
boundsOffsetLeft,
|
||||
boundsOffsetWidth,
|
||||
boundsOffsetHeight,
|
||||
]);
|
||||
|
||||
const [activeValue, setActiveValue] = React.useState<string | null>(
|
||||
value ?? defaultValue ?? null,
|
||||
);
|
||||
const [boundsState, setBoundsState] = React.useState<Bounds | null>(null);
|
||||
const [activeClassNameState, setActiveClassNameState] =
|
||||
React.useState<string>('');
|
||||
|
||||
const safeSetActiveValue = (id: string | null) => {
|
||||
setActiveValue((prev) => {
|
||||
if (prev !== id) {
|
||||
onValueChange?.(id);
|
||||
return id;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
const safeSetBoundsRef = React.useRef<
|
||||
((bounds: DOMRect) => void) | undefined
|
||||
>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
safeSetBoundsRef.current = (bounds: DOMRect) => {
|
||||
if (!localRef.current) return;
|
||||
|
||||
const containerRect = localRef.current.getBoundingClientRect();
|
||||
const offset = boundsOffsetRef.current;
|
||||
const newBounds: Bounds = {
|
||||
top: bounds.top - containerRect.top + offset.top,
|
||||
left: bounds.left - containerRect.left + offset.left,
|
||||
width: bounds.width + offset.width,
|
||||
height: bounds.height + offset.height,
|
||||
};
|
||||
|
||||
setBoundsState((prev) => {
|
||||
if (
|
||||
prev &&
|
||||
prev.top === newBounds.top &&
|
||||
prev.left === newBounds.left &&
|
||||
prev.width === newBounds.width &&
|
||||
prev.height === newBounds.height
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return newBounds;
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
const safeSetBounds = (bounds: DOMRect) => {
|
||||
safeSetBoundsRef.current?.(bounds);
|
||||
};
|
||||
|
||||
const clearBounds = React.useCallback(() => {
|
||||
setBoundsState((prev) => (prev === null ? prev : null));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (value !== undefined) setActiveValue(value);
|
||||
else if (defaultValue !== undefined) setActiveValue(defaultValue);
|
||||
}, [value, defaultValue]);
|
||||
|
||||
const id = React.useId();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== 'parent') return;
|
||||
const container = localRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const onScroll = () => {
|
||||
if (!activeValue) return;
|
||||
const activeEl = container.querySelector<HTMLElement>(
|
||||
`[data-value="${activeValue}"][data-highlight="true"]`,
|
||||
);
|
||||
if (activeEl)
|
||||
safeSetBoundsRef.current?.(activeEl.getBoundingClientRect());
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => container.removeEventListener('scroll', onScroll);
|
||||
}, [mode, activeValue]);
|
||||
|
||||
const render = (children: React.ReactNode) => {
|
||||
if (mode === 'parent') {
|
||||
return (
|
||||
<Component
|
||||
ref={localRef}
|
||||
data-slot="motion-highlight-container"
|
||||
style={{ position: 'relative', zIndex: 1 }}
|
||||
className={(props as ParentModeHighlightProps)?.containerClassName}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{boundsState && (
|
||||
<motion.div
|
||||
data-slot="motion-highlight"
|
||||
animate={{
|
||||
top: boundsState.top,
|
||||
left: boundsState.left,
|
||||
width: boundsState.width,
|
||||
height: boundsState.height,
|
||||
opacity: 1,
|
||||
}}
|
||||
initial={{
|
||||
top: boundsState.top,
|
||||
left: boundsState.left,
|
||||
width: boundsState.width,
|
||||
height: boundsState.height,
|
||||
opacity: 0,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
...transition,
|
||||
delay: (transition?.delay ?? 0) + (exitDelay ?? 0) / 1000,
|
||||
},
|
||||
}}
|
||||
transition={transition}
|
||||
style={{ position: 'absolute', zIndex: 0, ...style }}
|
||||
className={cn(className, activeClassNameState)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
return (
|
||||
<HighlightContext.Provider
|
||||
value={{
|
||||
mode,
|
||||
activeValue,
|
||||
setActiveValue: safeSetActiveValue,
|
||||
id,
|
||||
hover,
|
||||
click,
|
||||
className,
|
||||
style,
|
||||
transition,
|
||||
disabled,
|
||||
enabled,
|
||||
exitDelay,
|
||||
setBounds: safeSetBounds,
|
||||
clearBounds,
|
||||
activeClassName: activeClassNameState,
|
||||
setActiveClassName: setActiveClassNameState,
|
||||
forceUpdateBounds: (props as ParentModeHighlightProps)
|
||||
?.forceUpdateBounds,
|
||||
}}
|
||||
>
|
||||
{enabled
|
||||
? controlledItems
|
||||
? render(children)
|
||||
: render(
|
||||
React.Children.map(children, (child, index) => (
|
||||
<HighlightItem key={index} className={props?.itemsClassName}>
|
||||
{child}
|
||||
</HighlightItem>
|
||||
)),
|
||||
)
|
||||
: children}
|
||||
</HighlightContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function getNonOverridingDataAttributes(
|
||||
element: React.ReactElement,
|
||||
dataAttributes: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return Object.keys(dataAttributes).reduce<Record<string, unknown>>(
|
||||
(acc, key) => {
|
||||
if ((element.props as Record<string, unknown>)[key] === undefined) {
|
||||
acc[key] = dataAttributes[key];
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
type ExtendedChildProps = React.ComponentProps<'div'> & {
|
||||
id?: string;
|
||||
ref?: React.Ref<HTMLElement>;
|
||||
'data-active'?: string;
|
||||
'data-value'?: string;
|
||||
'data-disabled'?: boolean;
|
||||
'data-highlight'?: boolean;
|
||||
'data-slot'?: string;
|
||||
};
|
||||
|
||||
type HighlightItemProps<T extends React.ElementType = 'div'> =
|
||||
React.ComponentProps<T> & {
|
||||
as?: T;
|
||||
children: React.ReactElement;
|
||||
id?: string;
|
||||
value?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
transition?: Transition;
|
||||
activeClassName?: string;
|
||||
disabled?: boolean;
|
||||
exitDelay?: number;
|
||||
asChild?: boolean;
|
||||
forceUpdateBounds?: boolean;
|
||||
};
|
||||
|
||||
function HighlightItem<T extends React.ElementType>({
|
||||
ref,
|
||||
as,
|
||||
children,
|
||||
id,
|
||||
value,
|
||||
className,
|
||||
style,
|
||||
transition,
|
||||
disabled = false,
|
||||
activeClassName,
|
||||
exitDelay,
|
||||
asChild = false,
|
||||
forceUpdateBounds,
|
||||
...props
|
||||
}: HighlightItemProps<T>) {
|
||||
const itemId = React.useId();
|
||||
const {
|
||||
activeValue,
|
||||
setActiveValue,
|
||||
mode,
|
||||
setBounds,
|
||||
clearBounds,
|
||||
hover,
|
||||
click,
|
||||
enabled,
|
||||
className: contextClassName,
|
||||
style: contextStyle,
|
||||
transition: contextTransition,
|
||||
id: contextId,
|
||||
disabled: contextDisabled,
|
||||
exitDelay: contextExitDelay,
|
||||
forceUpdateBounds: contextForceUpdateBounds,
|
||||
setActiveClassName,
|
||||
} = useHighlight();
|
||||
|
||||
const Component = as ?? 'div';
|
||||
const element = children as React.ReactElement<ExtendedChildProps>;
|
||||
const childValue =
|
||||
id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId;
|
||||
const isActive = activeValue === childValue;
|
||||
const isDisabled = disabled === undefined ? contextDisabled : disabled;
|
||||
const itemTransition = transition ?? contextTransition;
|
||||
|
||||
const localRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
|
||||
|
||||
const refCallback = React.useCallback((node: HTMLElement | null) => {
|
||||
localRef.current = node as HTMLDivElement;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (mode !== 'parent') return;
|
||||
let rafId: number;
|
||||
let previousBounds: Bounds | null = null;
|
||||
const shouldUpdateBounds =
|
||||
forceUpdateBounds === true ||
|
||||
(contextForceUpdateBounds && forceUpdateBounds !== false);
|
||||
|
||||
const updateBounds = () => {
|
||||
if (!localRef.current) return;
|
||||
|
||||
const bounds = localRef.current.getBoundingClientRect();
|
||||
|
||||
if (shouldUpdateBounds) {
|
||||
if (
|
||||
previousBounds &&
|
||||
previousBounds.top === bounds.top &&
|
||||
previousBounds.left === bounds.left &&
|
||||
previousBounds.width === bounds.width &&
|
||||
previousBounds.height === bounds.height
|
||||
) {
|
||||
rafId = requestAnimationFrame(updateBounds);
|
||||
return;
|
||||
}
|
||||
previousBounds = bounds;
|
||||
rafId = requestAnimationFrame(updateBounds);
|
||||
}
|
||||
|
||||
setBounds(bounds);
|
||||
};
|
||||
|
||||
if (isActive) {
|
||||
updateBounds();
|
||||
setActiveClassName(activeClassName ?? '');
|
||||
} else if (!activeValue) clearBounds();
|
||||
|
||||
if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId);
|
||||
}, [
|
||||
mode,
|
||||
isActive,
|
||||
activeValue,
|
||||
setBounds,
|
||||
clearBounds,
|
||||
activeClassName,
|
||||
setActiveClassName,
|
||||
forceUpdateBounds,
|
||||
contextForceUpdateBounds,
|
||||
]);
|
||||
|
||||
if (!React.isValidElement(children)) return children;
|
||||
|
||||
const dataAttributes = {
|
||||
'data-active': isActive ? 'true' : 'false',
|
||||
'aria-selected': isActive,
|
||||
'data-disabled': isDisabled,
|
||||
'data-value': childValue,
|
||||
'data-highlight': true,
|
||||
};
|
||||
|
||||
const commonHandlers = hover
|
||||
? {
|
||||
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setActiveValue(childValue);
|
||||
element.props.onMouseEnter?.(e);
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setActiveValue(null);
|
||||
element.props.onMouseLeave?.(e);
|
||||
},
|
||||
}
|
||||
: click
|
||||
? {
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setActiveValue(childValue);
|
||||
element.props.onClick?.(e);
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
if (asChild) {
|
||||
if (mode === 'children') {
|
||||
return React.cloneElement(
|
||||
element,
|
||||
{
|
||||
key: childValue,
|
||||
ref: refCallback,
|
||||
className: cn('relative', element.props.className),
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item-container',
|
||||
}),
|
||||
...commonHandlers,
|
||||
...props,
|
||||
},
|
||||
<>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{isActive && !isDisabled && (
|
||||
<motion.div
|
||||
layoutId={`transition-background-${contextId}`}
|
||||
data-slot="motion-highlight"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 0,
|
||||
...contextStyle,
|
||||
...style,
|
||||
}}
|
||||
className={cn(contextClassName, activeClassName)}
|
||||
transition={itemTransition}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
...itemTransition,
|
||||
delay:
|
||||
(itemTransition?.delay ?? 0) +
|
||||
(exitDelay ?? contextExitDelay ?? 0) / 1000,
|
||||
},
|
||||
}}
|
||||
{...dataAttributes}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Component
|
||||
data-slot="motion-highlight-item"
|
||||
style={{ position: 'relative', zIndex: 1 }}
|
||||
className={className}
|
||||
{...dataAttributes}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
return React.cloneElement(element, {
|
||||
ref: refCallback,
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item',
|
||||
}),
|
||||
...commonHandlers,
|
||||
});
|
||||
}
|
||||
|
||||
return enabled ? (
|
||||
<Component
|
||||
key={childValue}
|
||||
ref={localRef}
|
||||
data-slot="motion-highlight-item-container"
|
||||
className={cn(mode === 'children' && 'relative', className)}
|
||||
{...dataAttributes}
|
||||
{...props}
|
||||
{...commonHandlers}
|
||||
>
|
||||
{mode === 'children' && (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{isActive && !isDisabled && (
|
||||
<motion.div
|
||||
layoutId={`transition-background-${contextId}`}
|
||||
data-slot="motion-highlight"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 0,
|
||||
...contextStyle,
|
||||
...style,
|
||||
}}
|
||||
className={cn(contextClassName, activeClassName)}
|
||||
transition={itemTransition}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
...itemTransition,
|
||||
delay:
|
||||
(itemTransition?.delay ?? 0) +
|
||||
(exitDelay ?? contextExitDelay ?? 0) / 1000,
|
||||
},
|
||||
}}
|
||||
{...dataAttributes}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{React.cloneElement(element, {
|
||||
style: { position: 'relative', zIndex: 1 },
|
||||
className: element.props.className,
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item',
|
||||
}),
|
||||
})}
|
||||
</Component>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Highlight,
|
||||
HighlightItem,
|
||||
useHighlight,
|
||||
type HighlightProps,
|
||||
type HighlightItemProps,
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
|
||||
type DialogContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: DialogProps['onOpenChange'];
|
||||
};
|
||||
|
||||
const [DialogProvider, useDialog] =
|
||||
getStrictContext<DialogContextType>('DialogContext');
|
||||
|
||||
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>;
|
||||
|
||||
function Dialog(props: DialogProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<DialogProvider value={{ isOpen, setIsOpen }}>
|
||||
<DialogPrimitive.Root
|
||||
data-slot="dialog"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogTriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>;
|
||||
|
||||
function DialogTrigger(props: DialogTriggerProps) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type DialogPortalProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Portal>,
|
||||
'forceMount'
|
||||
>;
|
||||
|
||||
function DialogPortal(props: DialogPortalProps) {
|
||||
const { isOpen } = useDialog();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DialogPrimitive.Portal
|
||||
data-slot="dialog-portal"
|
||||
forceMount
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogOverlayProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Overlay>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DialogOverlay({
|
||||
transition = { duration: 0.2, ease: 'easeInOut' },
|
||||
...props
|
||||
}: DialogOverlayProps) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay data-slot="dialog-overlay" asChild forceMount>
|
||||
<motion.div
|
||||
key="dialog-overlay"
|
||||
initial={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPrimitive.Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogFlipDirection = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
type DialogContentProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Content>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'> & {
|
||||
from?: DialogFlipDirection;
|
||||
};
|
||||
|
||||
function DialogContent({
|
||||
from = 'top',
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onInteractOutside,
|
||||
transition = { type: 'spring', stiffness: 150, damping: 25 },
|
||||
...props
|
||||
}: DialogContentProps) {
|
||||
const initialRotation =
|
||||
from === 'bottom' || from === 'left' ? '20deg' : '-20deg';
|
||||
const isVertical = from === 'top' || from === 'bottom';
|
||||
const rotateAxis = isVertical ? 'rotateX' : 'rotateY';
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Content
|
||||
asChild
|
||||
forceMount
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
>
|
||||
<motion.div
|
||||
key="dialog-content"
|
||||
data-slot="dialog-content"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
filter: 'blur(4px)',
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)',
|
||||
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
filter: 'blur(4px)',
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogCloseProps = React.ComponentProps<typeof DialogPrimitive.Close>;
|
||||
|
||||
function DialogClose(props: DialogCloseProps) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
type DialogHeaderProps = React.ComponentProps<'div'>;
|
||||
|
||||
function DialogHeader(props: DialogHeaderProps) {
|
||||
return <div data-slot="dialog-header" {...props} />;
|
||||
}
|
||||
|
||||
type DialogFooterProps = React.ComponentProps<'div'>;
|
||||
|
||||
function DialogFooter(props: DialogFooterProps) {
|
||||
return <div data-slot="dialog-footer" {...props} />;
|
||||
}
|
||||
|
||||
type DialogTitleProps = React.ComponentProps<typeof DialogPrimitive.Title>;
|
||||
|
||||
function DialogTitle(props: DialogTitleProps) {
|
||||
return <DialogPrimitive.Title data-slot="dialog-title" {...props} />;
|
||||
}
|
||||
|
||||
type DialogDescriptionProps = React.ComponentProps<
|
||||
typeof DialogPrimitive.Description
|
||||
>;
|
||||
|
||||
function DialogDescription(props: DialogDescriptionProps) {
|
||||
return (
|
||||
<DialogPrimitive.Description data-slot="dialog-description" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
useDialog,
|
||||
type DialogProps,
|
||||
type DialogTriggerProps,
|
||||
type DialogPortalProps,
|
||||
type DialogCloseProps,
|
||||
type DialogOverlayProps,
|
||||
type DialogContentProps,
|
||||
type DialogHeaderProps,
|
||||
type DialogFooterProps,
|
||||
type DialogTitleProps,
|
||||
type DialogDescriptionProps,
|
||||
type DialogContextType,
|
||||
type DialogFlipDirection,
|
||||
};
|
||||
@@ -0,0 +1,563 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
|
||||
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import {
|
||||
Highlight,
|
||||
HighlightItem,
|
||||
type HighlightItemProps,
|
||||
type HighlightProps,
|
||||
} from '@/components/animate-ui/primitives/effects/highlight';
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
import { useDataState } from '@/hooks/use-data-state';
|
||||
|
||||
type DropdownMenuContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (o: boolean) => void;
|
||||
highlightedValue: string | null;
|
||||
setHighlightedValue: (value: string | null) => void;
|
||||
};
|
||||
|
||||
type DropdownMenuSubContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (o: boolean) => void;
|
||||
};
|
||||
|
||||
const [DropdownMenuProvider, useDropdownMenu] =
|
||||
getStrictContext<DropdownMenuContextType>('DropdownMenuContext');
|
||||
|
||||
const [DropdownMenuSubProvider, useDropdownMenuSub] =
|
||||
getStrictContext<DropdownMenuSubContextType>('DropdownMenuSubContext');
|
||||
|
||||
type DropdownMenuProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Root
|
||||
>;
|
||||
|
||||
function DropdownMenu(props: DropdownMenuProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
const [highlightedValue, setHighlightedValue] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuProvider
|
||||
value={{ isOpen, setIsOpen, highlightedValue, setHighlightedValue }}
|
||||
>
|
||||
<DropdownMenuPrimitive.Root
|
||||
data-slot="dropdown-menu"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</DropdownMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuTriggerProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Trigger
|
||||
>;
|
||||
|
||||
function DropdownMenuTrigger(props: DropdownMenuTriggerProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuPortalProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Portal
|
||||
>;
|
||||
|
||||
function DropdownMenuPortal(props: DropdownMenuPortalProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuGroupProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Group
|
||||
>;
|
||||
|
||||
function DropdownMenuGroup(props: DropdownMenuGroupProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSubProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Sub
|
||||
>;
|
||||
|
||||
function DropdownMenuSub(props: DropdownMenuSubProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuSubProvider value={{ isOpen, setIsOpen }}>
|
||||
<DropdownMenuPrimitive.Sub
|
||||
data-slot="dropdown-menu-sub"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</DropdownMenuSubProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuRadioGroupProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.RadioGroup
|
||||
>;
|
||||
|
||||
function DropdownMenuRadioGroup(props: DropdownMenuRadioGroupProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSubTriggerProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
disabled,
|
||||
textValue,
|
||||
...props
|
||||
}: DropdownMenuSubTriggerProps) {
|
||||
const { setHighlightedValue } = useDropdownMenu();
|
||||
const [, highlightedRef] = useDataState<HTMLDivElement>(
|
||||
'highlighted',
|
||||
undefined,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
const el = highlightedRef.current;
|
||||
const v = el?.dataset.value || el?.id || null;
|
||||
if (v) setHighlightedValue(v);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={highlightedRef}
|
||||
disabled={disabled}
|
||||
textValue={textValue}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSubContentProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.Portal>,
|
||||
'forceMount'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
loop,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onFocusOutside,
|
||||
onInteractOutside,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
transition = { duration: 0.2 },
|
||||
style,
|
||||
container,
|
||||
...props
|
||||
}: DropdownMenuSubContentProps) {
|
||||
const { isOpen } = useDropdownMenuSub();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DropdownMenuPortal forceMount container={container}>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
asChild
|
||||
forceMount
|
||||
loop={loop}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onFocusOutside={onFocusOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
>
|
||||
<motion.div
|
||||
key="dropdown-menu-sub-content"
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={transition}
|
||||
style={{ willChange: 'opacity, transform', ...style }}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.SubContent>
|
||||
</DropdownMenuPortal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuHighlightProps = Omit<
|
||||
HighlightProps,
|
||||
'controlledItems' | 'enabled' | 'hover'
|
||||
> & {
|
||||
animateOnHover?: boolean;
|
||||
};
|
||||
|
||||
function DropdownMenuHighlight({
|
||||
transition = { type: 'spring', stiffness: 350, damping: 35 },
|
||||
...props
|
||||
}: DropdownMenuHighlightProps) {
|
||||
const { highlightedValue } = useDropdownMenu();
|
||||
|
||||
return (
|
||||
<Highlight
|
||||
data-slot="dropdown-menu-highlight"
|
||||
click={false}
|
||||
controlledItems
|
||||
transition={transition}
|
||||
value={highlightedValue}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuContentProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.Content>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.Portal>,
|
||||
'forceMount'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuContent({
|
||||
loop,
|
||||
onCloseAutoFocus,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onFocusOutside,
|
||||
onInteractOutside,
|
||||
side,
|
||||
sideOffset,
|
||||
align,
|
||||
alignOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
transition = { duration: 0.2 },
|
||||
style,
|
||||
container,
|
||||
...props
|
||||
}: DropdownMenuContentProps) {
|
||||
const { isOpen } = useDropdownMenu();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DropdownMenuPortal forceMount container={container}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
asChild
|
||||
loop={loop}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onFocusOutside={onFocusOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
>
|
||||
<motion.div
|
||||
key="dropdown-menu-content"
|
||||
data-slot="dropdown-menu-content"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={transition}
|
||||
style={{ willChange: 'opacity, transform', ...style }}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPortal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuHighlightItemProps = HighlightItemProps;
|
||||
|
||||
function DropdownMenuHighlightItem(props: DropdownMenuHighlightItemProps) {
|
||||
return <HighlightItem data-slot="dropdown-menu-highlight-item" {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuItemProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.Item>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuItem({
|
||||
disabled,
|
||||
onSelect,
|
||||
textValue,
|
||||
...props
|
||||
}: DropdownMenuItemProps) {
|
||||
const { setHighlightedValue } = useDropdownMenu();
|
||||
const [, highlightedRef] = useDataState<HTMLDivElement>(
|
||||
'highlighted',
|
||||
undefined,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
const el = highlightedRef.current;
|
||||
const v = el?.dataset.value || el?.id || null;
|
||||
if (v) setHighlightedValue(v);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={highlightedRef}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
textValue={textValue}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
data-slot="dropdown-menu-item"
|
||||
data-disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuCheckboxItemProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
onSelect,
|
||||
textValue,
|
||||
...props
|
||||
}: DropdownMenuCheckboxItemProps) {
|
||||
const { setHighlightedValue } = useDropdownMenu();
|
||||
const [, highlightedRef] = useDataState<HTMLDivElement>(
|
||||
'highlighted',
|
||||
undefined,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
const el = highlightedRef.current;
|
||||
const v = el?.dataset.value || el?.id || null;
|
||||
if (v) setHighlightedValue(v);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={highlightedRef}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
textValue={textValue}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuRadioItemProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
value,
|
||||
disabled,
|
||||
onSelect,
|
||||
textValue,
|
||||
...props
|
||||
}: DropdownMenuRadioItemProps) {
|
||||
const { setHighlightedValue } = useDropdownMenu();
|
||||
const [, highlightedRef] = useDataState<HTMLDivElement>(
|
||||
'highlighted',
|
||||
undefined,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
const el = highlightedRef.current;
|
||||
const v = el?.dataset.value || el?.id || null;
|
||||
if (v) setHighlightedValue(v);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={highlightedRef}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
textValue={textValue}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuLabelProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Label
|
||||
>;
|
||||
|
||||
function DropdownMenuLabel(props: DropdownMenuLabelProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSeparatorProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Separator
|
||||
>;
|
||||
|
||||
function DropdownMenuSeparator(props: DropdownMenuSeparatorProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuShortcutProps = React.ComponentProps<'span'>;
|
||||
|
||||
function DropdownMenuShortcut(props: DropdownMenuShortcutProps) {
|
||||
return <span data-slot="dropdown-menu-shortcut" {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuItemIndicatorProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.ItemIndicator>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuItemIndicator(props: DropdownMenuItemIndicatorProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.ItemIndicator
|
||||
data-slot="dropdown-menu-item-indicator"
|
||||
asChild
|
||||
>
|
||||
<motion.div {...props} />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuHighlight,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuHighlightItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
useDropdownMenu,
|
||||
useDropdownMenuSub,
|
||||
type DropdownMenuProps,
|
||||
type DropdownMenuTriggerProps,
|
||||
type DropdownMenuHighlightProps,
|
||||
type DropdownMenuContentProps,
|
||||
type DropdownMenuItemProps,
|
||||
type DropdownMenuItemIndicatorProps,
|
||||
type DropdownMenuHighlightItemProps,
|
||||
type DropdownMenuCheckboxItemProps,
|
||||
type DropdownMenuRadioItemProps,
|
||||
type DropdownMenuLabelProps,
|
||||
type DropdownMenuSeparatorProps,
|
||||
type DropdownMenuShortcutProps,
|
||||
type DropdownMenuGroupProps,
|
||||
type DropdownMenuPortalProps,
|
||||
type DropdownMenuSubProps,
|
||||
type DropdownMenuSubContentProps,
|
||||
type DropdownMenuSubTriggerProps,
|
||||
type DropdownMenuRadioGroupProps,
|
||||
type DropdownMenuContextType,
|
||||
type DropdownMenuSubContextType,
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { HoverCard as HoverCardPrimitive } from 'radix-ui';
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
type MotionValue,
|
||||
type HTMLMotionProps,
|
||||
type SpringOptions,
|
||||
} from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type HoverCardContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
x: MotionValue<number>;
|
||||
y: MotionValue<number>;
|
||||
followCursor?: boolean | 'x' | 'y';
|
||||
followCursorSpringOptions?: SpringOptions;
|
||||
};
|
||||
|
||||
const [HoverCardProvider, useHoverCard] =
|
||||
getStrictContext<HoverCardContextType>('HoverCardContext');
|
||||
|
||||
type HoverCardProps = React.ComponentProps<typeof HoverCardPrimitive.Root> & {
|
||||
followCursor?: boolean | 'x' | 'y';
|
||||
followCursorSpringOptions?: SpringOptions;
|
||||
};
|
||||
|
||||
function HoverCard({
|
||||
followCursor = false,
|
||||
followCursorSpringOptions = { stiffness: 200, damping: 17 },
|
||||
...props
|
||||
}: HoverCardProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
|
||||
return (
|
||||
<HoverCardProvider
|
||||
value={{
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
x,
|
||||
y,
|
||||
followCursor,
|
||||
followCursorSpringOptions,
|
||||
}}
|
||||
>
|
||||
<HoverCardPrimitive.Root
|
||||
data-slot="hover-card"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</HoverCardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type HoverCardTriggerProps = React.ComponentProps<
|
||||
typeof HoverCardPrimitive.Trigger
|
||||
>;
|
||||
|
||||
function HoverCardTrigger({ onMouseMove, ...props }: HoverCardTriggerProps) {
|
||||
const { x, y, followCursor } = useHoverCard();
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
onMouseMove?.(event);
|
||||
|
||||
const target = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
if (followCursor === 'x' || followCursor === true) {
|
||||
const eventOffsetX = event.clientX - target.left;
|
||||
const offsetXFromCenter = (eventOffsetX - target.width / 2) / 2;
|
||||
x.set(offsetXFromCenter);
|
||||
}
|
||||
|
||||
if (followCursor === 'y' || followCursor === true) {
|
||||
const eventOffsetY = event.clientY - target.top;
|
||||
const offsetYFromCenter = (eventOffsetY - target.height / 2) / 2;
|
||||
y.set(offsetYFromCenter);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger
|
||||
data-slot="hover-card-trigger"
|
||||
onMouseMove={handleMouseMove}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type HoverCardPortalProps = Omit<
|
||||
React.ComponentProps<typeof HoverCardPrimitive.Portal>,
|
||||
'forceMount'
|
||||
>;
|
||||
|
||||
function HoverCardPortal(props: HoverCardPortalProps) {
|
||||
const { isOpen } = useHoverCard();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<HoverCardPrimitive.Portal
|
||||
forceMount
|
||||
data-slot="hover-card-portal"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type HoverCardContentProps = React.ComponentProps<
|
||||
typeof HoverCardPrimitive.Content
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function HoverCardContent({
|
||||
align,
|
||||
alignOffset,
|
||||
side,
|
||||
sideOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
style,
|
||||
transition = { type: 'spring', stiffness: 300, damping: 25 },
|
||||
...props
|
||||
}: HoverCardContentProps) {
|
||||
const { x, y, followCursor, followCursorSpringOptions } = useHoverCard();
|
||||
const translateX = useSpring(x, followCursorSpringOptions);
|
||||
const translateY = useSpring(y, followCursorSpringOptions);
|
||||
|
||||
return (
|
||||
<HoverCardPrimitive.Content
|
||||
asChild
|
||||
forceMount
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
>
|
||||
<motion.div
|
||||
key="hover-card-content"
|
||||
data-slot="hover-card-content"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={transition}
|
||||
style={{
|
||||
x:
|
||||
followCursor === 'x' || followCursor === true
|
||||
? translateX
|
||||
: undefined,
|
||||
y:
|
||||
followCursor === 'y' || followCursor === true
|
||||
? translateY
|
||||
: undefined,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type HoverCardArrowProps = React.ComponentProps<
|
||||
typeof HoverCardPrimitive.Arrow
|
||||
>;
|
||||
|
||||
function HoverCardArrow(props: HoverCardArrowProps) {
|
||||
return <HoverCardPrimitive.Arrow data-slot="hover-card-arrow" {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
HoverCard,
|
||||
HoverCardTrigger,
|
||||
HoverCardPortal,
|
||||
HoverCardContent,
|
||||
HoverCardArrow,
|
||||
useHoverCard,
|
||||
type HoverCardProps,
|
||||
type HoverCardTriggerProps,
|
||||
type HoverCardPortalProps,
|
||||
type HoverCardContentProps,
|
||||
type HoverCardArrowProps,
|
||||
type HoverCardContextType,
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Popover as PopoverPrimitive } from 'radix-ui';
|
||||
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type PopoverContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
const [PopoverProvider, usePopover] =
|
||||
getStrictContext<PopoverContextType>('PopoverContext');
|
||||
|
||||
type PopoverProps = React.ComponentProps<typeof PopoverPrimitive.Root>;
|
||||
|
||||
function Popover(props: PopoverProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<PopoverProvider value={{ isOpen, setIsOpen }}>
|
||||
<PopoverPrimitive.Root
|
||||
data-slot="popover"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</PopoverProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type PopoverTriggerProps = React.ComponentProps<
|
||||
typeof PopoverPrimitive.Trigger
|
||||
>;
|
||||
|
||||
function PopoverTrigger(props: PopoverTriggerProps) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type PopoverPortalProps = Omit<
|
||||
React.ComponentProps<typeof PopoverPrimitive.Portal>,
|
||||
'forceMount'
|
||||
>;
|
||||
|
||||
function PopoverPortal(props: PopoverPortalProps) {
|
||||
const { isOpen } = usePopover();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<PopoverPrimitive.Portal
|
||||
forceMount
|
||||
data-slot="popover-portal"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type PopoverContentProps = Omit<
|
||||
React.ComponentProps<typeof PopoverPrimitive.Content>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function PopoverContent({
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onFocusOutside,
|
||||
onInteractOutside,
|
||||
align,
|
||||
alignOffset,
|
||||
side,
|
||||
sideOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
transition = { type: 'spring', stiffness: 300, damping: 25 },
|
||||
...props
|
||||
}: PopoverContentProps) {
|
||||
return (
|
||||
<PopoverPrimitive.Content
|
||||
asChild
|
||||
forceMount
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
onFocusOutside={onFocusOutside}
|
||||
>
|
||||
<motion.div
|
||||
key="popover-content"
|
||||
data-slot="popover-content"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type PopoverAnchorProps = React.ComponentProps<typeof PopoverPrimitive.Anchor>;
|
||||
|
||||
function PopoverAnchor({ ...props }: PopoverAnchorProps) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
type PopoverArrowProps = React.ComponentProps<typeof PopoverPrimitive.Arrow>;
|
||||
|
||||
function PopoverArrow(props: PopoverArrowProps) {
|
||||
return <PopoverPrimitive.Arrow data-slot="popover-arrow" {...props} />;
|
||||
}
|
||||
|
||||
type PopoverCloseProps = React.ComponentProps<typeof PopoverPrimitive.Close>;
|
||||
|
||||
function PopoverClose(props: PopoverCloseProps) {
|
||||
return <PopoverPrimitive.Close data-slot="popover-close" {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverPortal,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverClose,
|
||||
PopoverArrow,
|
||||
usePopover,
|
||||
type PopoverProps,
|
||||
type PopoverTriggerProps,
|
||||
type PopoverPortalProps,
|
||||
type PopoverContentProps,
|
||||
type PopoverAnchorProps,
|
||||
type PopoverCloseProps,
|
||||
type PopoverArrowProps,
|
||||
type PopoverContextType,
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Dialog as SheetPrimitive } from 'radix-ui';
|
||||
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type SheetContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
const [SheetProvider, useSheet] =
|
||||
getStrictContext<SheetContextType>('SheetContext');
|
||||
|
||||
type SheetProps = React.ComponentProps<typeof SheetPrimitive.Root>;
|
||||
|
||||
function Sheet(props: SheetProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props.open,
|
||||
defaultValue: props.defaultOpen,
|
||||
onChange: props.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<SheetProvider value={{ isOpen, setIsOpen }}>
|
||||
<SheetPrimitive.Root
|
||||
data-slot="sheet"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</SheetProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type SheetTriggerProps = React.ComponentProps<typeof SheetPrimitive.Trigger>;
|
||||
|
||||
function SheetTrigger(props: SheetTriggerProps) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type SheetCloseProps = React.ComponentProps<typeof SheetPrimitive.Close>;
|
||||
|
||||
function SheetClose(props: SheetCloseProps) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
type SheetPortalProps = React.ComponentProps<typeof SheetPrimitive.Portal>;
|
||||
|
||||
function SheetPortal(props: SheetPortalProps) {
|
||||
const { isOpen } = useSheet();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<SheetPrimitive.Portal forceMount data-slot="sheet-portal" {...props} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type SheetOverlayProps = Omit<
|
||||
React.ComponentProps<typeof SheetPrimitive.Overlay>,
|
||||
'asChild' | 'forceMount'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function SheetOverlay({
|
||||
transition = { duration: 0.2, ease: 'easeInOut' },
|
||||
...props
|
||||
}: SheetOverlayProps) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay asChild forceMount>
|
||||
<motion.div
|
||||
key="sheet-overlay"
|
||||
data-slot="sheet-overlay"
|
||||
initial={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</SheetPrimitive.Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
type Side = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
type SheetContentProps = React.ComponentProps<typeof SheetPrimitive.Content> &
|
||||
HTMLMotionProps<'div'> & {
|
||||
side?: Side;
|
||||
};
|
||||
|
||||
function SheetContent({
|
||||
side = 'right',
|
||||
transition = { type: 'spring', stiffness: 150, damping: 22 },
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: SheetContentProps) {
|
||||
const axis = side === 'left' || side === 'right' ? 'x' : 'y';
|
||||
|
||||
const offscreen: Record<Side, { x?: string; y?: string; opacity: number }> = {
|
||||
right: { x: '100%', opacity: 0 },
|
||||
left: { x: '-100%', opacity: 0 },
|
||||
top: { y: '-100%', opacity: 0 },
|
||||
bottom: { y: '100%', opacity: 0 },
|
||||
};
|
||||
|
||||
const positionStyle: Record<Side, React.CSSProperties> = {
|
||||
right: { insetBlock: 0, right: 0 },
|
||||
left: { insetBlock: 0, left: 0 },
|
||||
top: { insetInline: 0, top: 0 },
|
||||
bottom: { insetInline: 0, bottom: 0 },
|
||||
};
|
||||
|
||||
return (
|
||||
<SheetPrimitive.Content asChild forceMount {...props}>
|
||||
<motion.div
|
||||
key="sheet-content"
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
initial={offscreen[side]}
|
||||
animate={{ [axis]: 0, opacity: 1 }}
|
||||
exit={offscreen[side]}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
...positionStyle[side],
|
||||
...style,
|
||||
}}
|
||||
transition={transition}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</SheetPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type SheetHeaderProps = React.ComponentProps<'div'>;
|
||||
|
||||
function SheetHeader(props: SheetHeaderProps) {
|
||||
return <div data-slot="sheet-header" {...props} />;
|
||||
}
|
||||
|
||||
type SheetFooterProps = React.ComponentProps<'div'>;
|
||||
|
||||
function SheetFooter(props: SheetFooterProps) {
|
||||
return <div data-slot="sheet-footer" {...props} />;
|
||||
}
|
||||
|
||||
type SheetTitleProps = React.ComponentProps<typeof SheetPrimitive.Title>;
|
||||
|
||||
function SheetTitle(props: SheetTitleProps) {
|
||||
return <SheetPrimitive.Title data-slot="sheet-title" {...props} />;
|
||||
}
|
||||
|
||||
type SheetDescriptionProps = React.ComponentProps<
|
||||
typeof SheetPrimitive.Description
|
||||
>;
|
||||
|
||||
function SheetDescription(props: SheetDescriptionProps) {
|
||||
return (
|
||||
<SheetPrimitive.Description data-slot="sheet-description" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
useSheet,
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
type SheetProps,
|
||||
type SheetPortalProps,
|
||||
type SheetOverlayProps,
|
||||
type SheetTriggerProps,
|
||||
type SheetCloseProps,
|
||||
type SheetContentProps,
|
||||
type SheetHeaderProps,
|
||||
type SheetFooterProps,
|
||||
type SheetTitleProps,
|
||||
type SheetDescriptionProps,
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Switch as SwitchPrimitives } from 'radix-ui';
|
||||
import {
|
||||
motion,
|
||||
type TargetAndTransition,
|
||||
type VariantLabels,
|
||||
type HTMLMotionProps,
|
||||
type LegacyAnimationControls,
|
||||
} from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type SwitchContextType = {
|
||||
isChecked: boolean;
|
||||
setIsChecked: (isChecked: boolean) => void;
|
||||
isPressed: boolean;
|
||||
setIsPressed: (isPressed: boolean) => void;
|
||||
};
|
||||
|
||||
const [SwitchProvider, useSwitch] =
|
||||
getStrictContext<SwitchContextType>('SwitchContext');
|
||||
|
||||
type SwitchProps = Omit<
|
||||
React.ComponentProps<typeof SwitchPrimitives.Root>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'button'>;
|
||||
|
||||
function Switch(props: SwitchProps) {
|
||||
// Destructure Radix-only props so they don't leak onto the motion.button DOM element
|
||||
const {
|
||||
checked,
|
||||
defaultChecked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
required,
|
||||
name,
|
||||
value,
|
||||
form,
|
||||
...motionProps
|
||||
} = props;
|
||||
|
||||
const [isPressed, setIsPressed] = React.useState(false);
|
||||
const [isChecked, setIsChecked] = useControlledState({
|
||||
value: checked,
|
||||
defaultValue: defaultChecked,
|
||||
onChange: onCheckedChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<SwitchProvider
|
||||
value={{ isChecked, setIsChecked, isPressed, setIsPressed }}
|
||||
>
|
||||
<SwitchPrimitives.Root
|
||||
checked={checked}
|
||||
defaultChecked={defaultChecked}
|
||||
onCheckedChange={setIsChecked}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
name={name}
|
||||
value={value}
|
||||
form={form}
|
||||
asChild
|
||||
>
|
||||
<motion.button
|
||||
data-slot="switch"
|
||||
whileTap="tap"
|
||||
initial={false}
|
||||
onTapStart={() => setIsPressed(true)}
|
||||
onTapCancel={() => setIsPressed(false)}
|
||||
onTap={() => setIsPressed(false)}
|
||||
{...motionProps}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
</SwitchProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type SwitchThumbProps = Omit<
|
||||
React.ComponentProps<typeof SwitchPrimitives.Thumb>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'> & {
|
||||
pressedAnimation?:
|
||||
| TargetAndTransition
|
||||
| VariantLabels
|
||||
| boolean
|
||||
| LegacyAnimationControls;
|
||||
};
|
||||
|
||||
function SwitchThumb({
|
||||
pressedAnimation,
|
||||
transition = { type: 'spring', stiffness: 300, damping: 25 },
|
||||
...props
|
||||
}: SwitchThumbProps) {
|
||||
const { isPressed } = useSwitch();
|
||||
|
||||
return (
|
||||
<SwitchPrimitives.Thumb asChild>
|
||||
<motion.div
|
||||
data-slot="switch-thumb"
|
||||
whileTap="tab"
|
||||
layout
|
||||
transition={transition}
|
||||
animate={isPressed ? pressedAnimation : undefined}
|
||||
{...props}
|
||||
/>
|
||||
</SwitchPrimitives.Thumb>
|
||||
);
|
||||
}
|
||||
|
||||
type SwitchIconPosition = 'left' | 'right' | 'thumb';
|
||||
|
||||
type SwitchIconProps = HTMLMotionProps<'div'> & {
|
||||
position: SwitchIconPosition;
|
||||
};
|
||||
|
||||
function SwitchIcon({
|
||||
position,
|
||||
transition = { type: 'spring', bounce: 0 },
|
||||
...props
|
||||
}: SwitchIconProps) {
|
||||
const { isChecked } = useSwitch();
|
||||
|
||||
const isAnimated = React.useMemo(() => {
|
||||
if (position === 'right') return !isChecked;
|
||||
if (position === 'left') return isChecked;
|
||||
if (position === 'thumb') return true;
|
||||
return false;
|
||||
}, [position, isChecked]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
data-slot={`switch-${position}-icon`}
|
||||
animate={isAnimated ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Switch,
|
||||
SwitchThumb,
|
||||
SwitchIcon,
|
||||
useSwitch,
|
||||
type SwitchProps,
|
||||
type SwitchThumbProps,
|
||||
type SwitchIconProps,
|
||||
type SwitchIconPosition,
|
||||
type SwitchContextType,
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Tabs as TabsPrimitive } from 'radix-ui';
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
type HTMLMotionProps,
|
||||
type Transition,
|
||||
} from 'motion/react';
|
||||
|
||||
import {
|
||||
Highlight,
|
||||
HighlightItem,
|
||||
type HighlightProps,
|
||||
type HighlightItemProps,
|
||||
} from '@/components/animate-ui/primitives/effects/highlight';
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
import {
|
||||
AutoHeight,
|
||||
type AutoHeightProps,
|
||||
} from '@/components/animate-ui/primitives/effects/auto-height';
|
||||
|
||||
type TabsContextType = {
|
||||
value: string | undefined;
|
||||
setValue: TabsProps['onValueChange'];
|
||||
};
|
||||
|
||||
const [TabsProvider, useTabs] =
|
||||
getStrictContext<TabsContextType>('TabsContext');
|
||||
|
||||
type TabsProps = React.ComponentProps<typeof TabsPrimitive.Root>;
|
||||
|
||||
function Tabs(props: TabsProps) {
|
||||
const [value, setValue] = useControlledState({
|
||||
value: props.value,
|
||||
defaultValue: props.defaultValue,
|
||||
onChange: props.onValueChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<TabsProvider value={{ value, setValue }}>
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
{...props}
|
||||
onValueChange={setValue}
|
||||
/>
|
||||
</TabsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type TabsHighlightProps = Omit<HighlightProps, 'controlledItems' | 'value'>;
|
||||
|
||||
function TabsHighlight({
|
||||
transition = { type: 'spring', stiffness: 200, damping: 25 },
|
||||
...props
|
||||
}: TabsHighlightProps) {
|
||||
const { value } = useTabs();
|
||||
|
||||
return (
|
||||
<Highlight
|
||||
data-slot="tabs-highlight"
|
||||
controlledItems
|
||||
value={value}
|
||||
transition={transition}
|
||||
click={false}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type TabsListProps = React.ComponentProps<typeof TabsPrimitive.List>;
|
||||
|
||||
function TabsList(props: TabsListProps) {
|
||||
return <TabsPrimitive.List data-slot="tabs-list" {...props} />;
|
||||
}
|
||||
|
||||
type TabsHighlightItemProps = HighlightItemProps & {
|
||||
value: string;
|
||||
};
|
||||
|
||||
function TabsHighlightItem(props: TabsHighlightItemProps) {
|
||||
return <HighlightItem data-slot="tabs-highlight-item" {...props} />;
|
||||
}
|
||||
|
||||
type TabsTriggerProps = React.ComponentProps<typeof TabsPrimitive.Trigger>;
|
||||
|
||||
function TabsTrigger(props: TabsTriggerProps) {
|
||||
return <TabsPrimitive.Trigger data-slot="tabs-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type TabsContentProps = React.ComponentProps<typeof TabsPrimitive.Content> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function TabsContent({
|
||||
value,
|
||||
forceMount,
|
||||
transition = { duration: 0.5, ease: 'easeInOut' },
|
||||
...props
|
||||
}: TabsContentProps) {
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<TabsPrimitive.Content asChild forceMount={forceMount} value={value}>
|
||||
<motion.div
|
||||
data-slot="tabs-content"
|
||||
layout
|
||||
layoutDependency={value}
|
||||
initial={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</TabsPrimitive.Content>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type TabsContentsAutoProps = AutoHeightProps & {
|
||||
mode?: 'auto-height';
|
||||
children: React.ReactNode;
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
type TabsContentsLayoutProps = Omit<HTMLMotionProps<'div'>, 'transition'> & {
|
||||
mode: 'layout';
|
||||
children: React.ReactNode;
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
type TabsContentsProps = TabsContentsAutoProps | TabsContentsLayoutProps;
|
||||
|
||||
const defaultTransition: Transition = {
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
function isAutoMode(props: TabsContentsProps): props is TabsContentsAutoProps {
|
||||
return !('mode' in props) || props.mode === 'auto-height';
|
||||
}
|
||||
|
||||
function TabsContents(props: TabsContentsProps) {
|
||||
const { value } = useTabs();
|
||||
|
||||
if (isAutoMode(props)) {
|
||||
const { transition = defaultTransition, ...autoProps } = props;
|
||||
|
||||
return (
|
||||
<AutoHeight
|
||||
data-slot="tabs-contents"
|
||||
deps={[value]}
|
||||
transition={transition}
|
||||
{...autoProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { transition = defaultTransition, style, ...layoutProps } = props;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
data-slot="tabs-contents"
|
||||
layout="size"
|
||||
layoutDependency={value}
|
||||
style={{ overflow: 'hidden', ...style }}
|
||||
transition={{ layout: transition }}
|
||||
{...layoutProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Tabs,
|
||||
TabsHighlight,
|
||||
TabsHighlightItem,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
TabsContents,
|
||||
type TabsProps,
|
||||
type TabsHighlightProps,
|
||||
type TabsHighlightItemProps,
|
||||
type TabsListProps,
|
||||
type TabsTriggerProps,
|
||||
type TabsContentProps,
|
||||
type TabsContentsProps,
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Tooltip as TooltipPrimitive } from 'radix-ui';
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
type SpringOptions,
|
||||
type HTMLMotionProps,
|
||||
type MotionValue,
|
||||
} from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type TooltipContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
x: MotionValue<number>;
|
||||
y: MotionValue<number>;
|
||||
followCursor?: boolean | 'x' | 'y';
|
||||
followCursorSpringOptions?: SpringOptions;
|
||||
};
|
||||
|
||||
const [LocalTooltipProvider, useTooltip] =
|
||||
getStrictContext<TooltipContextType>('TooltipContext');
|
||||
|
||||
type TooltipProviderProps = React.ComponentProps<
|
||||
typeof TooltipPrimitive.Provider
|
||||
>;
|
||||
|
||||
function TooltipProvider(props: TooltipProviderProps) {
|
||||
return <TooltipPrimitive.Provider data-slot="tooltip-provider" {...props} />;
|
||||
}
|
||||
|
||||
type TooltipProps = React.ComponentProps<typeof TooltipPrimitive.Root> & {
|
||||
followCursor?: boolean | 'x' | 'y';
|
||||
followCursorSpringOptions?: SpringOptions;
|
||||
};
|
||||
|
||||
function Tooltip({
|
||||
followCursor = false,
|
||||
followCursorSpringOptions = { stiffness: 200, damping: 17 },
|
||||
...props
|
||||
}: TooltipProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
|
||||
return (
|
||||
<LocalTooltipProvider
|
||||
value={{
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
x,
|
||||
y,
|
||||
followCursor,
|
||||
followCursorSpringOptions,
|
||||
}}
|
||||
>
|
||||
<TooltipPrimitive.Root
|
||||
data-slot="tooltip"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</LocalTooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipTriggerProps = React.ComponentProps<
|
||||
typeof TooltipPrimitive.Trigger
|
||||
>;
|
||||
|
||||
function TooltipTrigger({ onMouseMove, ...props }: TooltipTriggerProps) {
|
||||
const { x, y, followCursor } = useTooltip();
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onMouseMove?.(event);
|
||||
|
||||
const target = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
if (followCursor === 'x' || followCursor === true) {
|
||||
const eventOffsetX = event.clientX - target.left;
|
||||
const offsetXFromCenter = (eventOffsetX - target.width / 2) / 2;
|
||||
x.set(offsetXFromCenter);
|
||||
}
|
||||
|
||||
if (followCursor === 'y' || followCursor === true) {
|
||||
const eventOffsetY = event.clientY - target.top;
|
||||
const offsetYFromCenter = (eventOffsetY - target.height / 2) / 2;
|
||||
y.set(offsetYFromCenter);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipPrimitive.Trigger
|
||||
data-slot="tooltip-trigger"
|
||||
onMouseMove={handleMouseMove}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipPortalProps = Omit<
|
||||
React.ComponentProps<typeof TooltipPrimitive.Portal>,
|
||||
'forceMount'
|
||||
>;
|
||||
|
||||
function TooltipPortal(props: TooltipPortalProps) {
|
||||
const { isOpen } = useTooltip();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<TooltipPrimitive.Portal
|
||||
forceMount
|
||||
data-slot="tooltip-portal"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipContentProps = Omit<
|
||||
React.ComponentProps<typeof TooltipPrimitive.Content>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function TooltipContent({
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
side,
|
||||
sideOffset,
|
||||
align,
|
||||
alignOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
style,
|
||||
transition = { type: 'spring', stiffness: 300, damping: 25 },
|
||||
...props
|
||||
}: TooltipContentProps) {
|
||||
const { x, y, followCursor, followCursorSpringOptions } = useTooltip();
|
||||
const translateX = useSpring(x, followCursorSpringOptions);
|
||||
const translateY = useSpring(y, followCursorSpringOptions);
|
||||
|
||||
return (
|
||||
<TooltipPrimitive.Content
|
||||
asChild
|
||||
forceMount
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
>
|
||||
<motion.div
|
||||
key="popover-content"
|
||||
data-slot="popover-content"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={transition}
|
||||
style={{
|
||||
x:
|
||||
followCursor === 'x' || followCursor === true
|
||||
? translateX
|
||||
: undefined,
|
||||
y:
|
||||
followCursor === 'y' || followCursor === true
|
||||
? translateY
|
||||
: undefined,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipArrowProps = React.ComponentProps<typeof TooltipPrimitive.Arrow>;
|
||||
|
||||
function TooltipArrow(props: TooltipArrowProps) {
|
||||
return <TooltipPrimitive.Arrow data-slot="tooltip-arrow" {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipPortal,
|
||||
TooltipContent,
|
||||
TooltipArrow,
|
||||
useTooltip,
|
||||
type TooltipProviderProps,
|
||||
type TooltipProps,
|
||||
type TooltipTriggerProps,
|
||||
type TooltipPortalProps,
|
||||
type TooltipContentProps,
|
||||
type TooltipArrowProps,
|
||||
type TooltipContextType,
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useMotionValue, useSpring, type SpringOptions } from 'motion/react';
|
||||
|
||||
import {
|
||||
useIsInView,
|
||||
type UseIsInViewOptions,
|
||||
} from '@/hooks/use-is-in-view';
|
||||
|
||||
type CountingNumberProps = Omit<React.ComponentProps<'span'>, 'children'> & {
|
||||
number: number;
|
||||
fromNumber?: number;
|
||||
padStart?: boolean;
|
||||
decimalSeparator?: string;
|
||||
decimalPlaces?: number;
|
||||
transition?: SpringOptions;
|
||||
delay?: number;
|
||||
initiallyStable?: boolean;
|
||||
} & UseIsInViewOptions;
|
||||
|
||||
function CountingNumber({
|
||||
ref,
|
||||
number,
|
||||
fromNumber = 0,
|
||||
padStart = false,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
decimalSeparator = '.',
|
||||
transition = { stiffness: 90, damping: 50 },
|
||||
decimalPlaces = 0,
|
||||
delay = 0,
|
||||
initiallyStable = false,
|
||||
...props
|
||||
}: CountingNumberProps) {
|
||||
const { ref: localRef, isInView } = useIsInView(
|
||||
ref as React.Ref<HTMLElement>,
|
||||
{
|
||||
inView,
|
||||
inViewOnce,
|
||||
inViewMargin,
|
||||
},
|
||||
);
|
||||
|
||||
const numberStr = number.toString();
|
||||
const decimals =
|
||||
typeof decimalPlaces === 'number'
|
||||
? decimalPlaces
|
||||
: numberStr.includes('.')
|
||||
? (numberStr.split('.')[1]?.length ?? 0)
|
||||
: 0;
|
||||
|
||||
const motionVal = useMotionValue(initiallyStable ? number : fromNumber);
|
||||
const springVal = useSpring(motionVal, transition);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (isInView) motionVal.set(number);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [isInView, number, motionVal, delay]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = springVal.on('change', (latest) => {
|
||||
if (localRef.current) {
|
||||
let formatted =
|
||||
decimals > 0
|
||||
? latest.toFixed(decimals)
|
||||
: Math.round(latest).toString();
|
||||
|
||||
if (decimals > 0) {
|
||||
formatted = formatted.replace('.', decimalSeparator);
|
||||
}
|
||||
|
||||
if (padStart) {
|
||||
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
|
||||
const [intPart, fracPart] = formatted.split(decimalSeparator);
|
||||
const paddedInt = intPart?.padStart(finalIntLength, '0') ?? '';
|
||||
formatted = fracPart
|
||||
? `${paddedInt}${decimalSeparator}${fracPart}`
|
||||
: paddedInt;
|
||||
}
|
||||
|
||||
localRef.current.textContent = formatted;
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [springVal, decimals, padStart, number, decimalSeparator, localRef]);
|
||||
|
||||
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
|
||||
|
||||
const formatValue = (val: number) => {
|
||||
let out = decimals > 0 ? val.toFixed(decimals) : Math.round(val).toString();
|
||||
if (decimals > 0) out = out.replace('.', decimalSeparator);
|
||||
if (padStart) {
|
||||
const [intPart, fracPart] = out.split(decimalSeparator);
|
||||
const paddedInt = (intPart ?? '').padStart(finalIntLength, '0');
|
||||
out = fracPart ? `${paddedInt}${decimalSeparator}${fracPart}` : paddedInt;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const zeroText = padStart
|
||||
? '0'.padStart(finalIntLength, '0') +
|
||||
(decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '')
|
||||
: '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '');
|
||||
|
||||
const initialText = initiallyStable ? formatValue(number) : zeroText;
|
||||
|
||||
return (
|
||||
<span ref={localRef} data-slot="counting-number" {...props}>
|
||||
{initialText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export { CountingNumber, type CountingNumberProps };
|
||||
@@ -0,0 +1,353 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
useSpring,
|
||||
useTransform,
|
||||
motion,
|
||||
useMotionValue,
|
||||
type MotionValue,
|
||||
type SpringOptions,
|
||||
type HTMLMotionProps,
|
||||
} from 'motion/react';
|
||||
import useMeasure from 'react-use-measure';
|
||||
|
||||
import {
|
||||
useIsInView,
|
||||
type UseIsInViewOptions,
|
||||
} from '@/hooks/use-is-in-view';
|
||||
|
||||
type SlidingNumberRollerProps = {
|
||||
prevValue: number;
|
||||
value: number;
|
||||
place: number;
|
||||
transition: SpringOptions;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
function SlidingNumberRoller({
|
||||
prevValue,
|
||||
value,
|
||||
place,
|
||||
transition,
|
||||
delay = 0,
|
||||
}: SlidingNumberRollerProps) {
|
||||
const startNumber = Math.floor(prevValue / place) % 10;
|
||||
const targetNumber = Math.floor(value / place) % 10;
|
||||
const animatedValue = useSpring(startNumber, transition);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
animatedValue.set(targetNumber);
|
||||
}, delay);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [targetNumber, animatedValue, delay]);
|
||||
|
||||
const [measureRef, { height }] = useMeasure();
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={measureRef}
|
||||
data-slot="sliding-number-roller"
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
width: '1ch',
|
||||
overflowX: 'visible',
|
||||
overflowY: 'clip',
|
||||
lineHeight: 1,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
<span style={{ visibility: 'hidden' }}>0</span>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<SlidingNumberDisplay
|
||||
key={i}
|
||||
motionValue={animatedValue}
|
||||
number={i}
|
||||
height={height}
|
||||
transition={transition}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type SlidingNumberDisplayProps = {
|
||||
motionValue: MotionValue<number>;
|
||||
number: number;
|
||||
height: number;
|
||||
transition: SpringOptions;
|
||||
};
|
||||
|
||||
function SlidingNumberDisplay({
|
||||
motionValue,
|
||||
number,
|
||||
height,
|
||||
transition,
|
||||
}: SlidingNumberDisplayProps) {
|
||||
const y = useTransform(motionValue, (latest) => {
|
||||
if (!height) return 0;
|
||||
const currentNumber = latest % 10;
|
||||
const offset = (10 + number - currentNumber) % 10;
|
||||
let translateY = offset * height;
|
||||
if (offset > 5) translateY -= 10 * height;
|
||||
return translateY;
|
||||
});
|
||||
|
||||
if (!height) {
|
||||
return (
|
||||
<span style={{ visibility: 'hidden', position: 'absolute' }}>
|
||||
{number}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
data-slot="sliding-number-display"
|
||||
style={{
|
||||
y,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
transition={{ ...transition, type: 'spring' }}
|
||||
>
|
||||
{number}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
type SlidingNumberProps = Omit<HTMLMotionProps<'span'>, 'children'> & {
|
||||
number: number;
|
||||
fromNumber?: number;
|
||||
onNumberChange?: (number: number) => void;
|
||||
padStart?: boolean;
|
||||
decimalSeparator?: string;
|
||||
decimalPlaces?: number;
|
||||
thousandSeparator?: string;
|
||||
transition?: SpringOptions;
|
||||
delay?: number;
|
||||
initiallyStable?: boolean;
|
||||
} & UseIsInViewOptions;
|
||||
|
||||
function SlidingNumber({
|
||||
ref,
|
||||
number,
|
||||
fromNumber,
|
||||
onNumberChange,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
padStart = false,
|
||||
decimalSeparator = '.',
|
||||
decimalPlaces = 0,
|
||||
thousandSeparator,
|
||||
transition = { stiffness: 200, damping: 20, mass: 0.4 },
|
||||
delay = 0,
|
||||
initiallyStable = false,
|
||||
...props
|
||||
}: SlidingNumberProps) {
|
||||
const { ref: localRef, isInView } = useIsInView(
|
||||
ref as React.Ref<HTMLElement>,
|
||||
{
|
||||
inView,
|
||||
inViewOnce,
|
||||
inViewMargin,
|
||||
},
|
||||
);
|
||||
|
||||
const initialNumeric = Math.abs(Number(number));
|
||||
const prevNumberRef = React.useRef<number>(
|
||||
initiallyStable ? initialNumeric : 0,
|
||||
);
|
||||
|
||||
const hasAnimated = fromNumber !== undefined;
|
||||
|
||||
const motionVal = useMotionValue(
|
||||
initiallyStable ? initialNumeric : (fromNumber ?? 0),
|
||||
);
|
||||
const springVal = useSpring(motionVal, { stiffness: 90, damping: 50 });
|
||||
|
||||
const skippedInitialWhenStable = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasAnimated) return;
|
||||
if (initiallyStable && !skippedInitialWhenStable.current) {
|
||||
skippedInitialWhenStable.current = true;
|
||||
return;
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (isInView) motionVal.set(number);
|
||||
}, delay);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [hasAnimated, initiallyStable, isInView, number, motionVal, delay]);
|
||||
|
||||
const [effectiveNumber, setEffectiveNumber] = React.useState<number>(
|
||||
initiallyStable ? initialNumeric : 0,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasAnimated) {
|
||||
const inferredDecimals =
|
||||
typeof decimalPlaces === 'number' && decimalPlaces >= 0
|
||||
? decimalPlaces
|
||||
: (() => {
|
||||
const s = String(number);
|
||||
const idx = s.indexOf('.');
|
||||
return idx >= 0 ? s.length - idx - 1 : 0;
|
||||
})();
|
||||
|
||||
const factor = Math.pow(10, inferredDecimals);
|
||||
|
||||
const unsubscribe = springVal.on('change', (latest: number) => {
|
||||
const newValue =
|
||||
inferredDecimals > 0
|
||||
? Math.round(latest * factor) / factor
|
||||
: Math.round(latest);
|
||||
|
||||
if (effectiveNumber !== newValue) {
|
||||
setEffectiveNumber(newValue);
|
||||
onNumberChange?.(newValue);
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
} else {
|
||||
setEffectiveNumber(
|
||||
initiallyStable ? initialNumeric : !isInView ? 0 : initialNumeric,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
hasAnimated,
|
||||
springVal,
|
||||
isInView,
|
||||
number,
|
||||
decimalPlaces,
|
||||
onNumberChange,
|
||||
effectiveNumber,
|
||||
initiallyStable,
|
||||
initialNumeric,
|
||||
]);
|
||||
|
||||
const formatNumber = React.useCallback(
|
||||
(num: number) =>
|
||||
decimalPlaces != null ? num.toFixed(decimalPlaces) : num.toString(),
|
||||
[decimalPlaces],
|
||||
);
|
||||
|
||||
const numberStr = formatNumber(effectiveNumber);
|
||||
const [newIntStrRaw, newDecStrRaw = ''] = numberStr.split('.');
|
||||
|
||||
const finalIntLength = padStart
|
||||
? Math.max(
|
||||
Math.floor(Math.abs(number)).toString().length,
|
||||
newIntStrRaw.length,
|
||||
)
|
||||
: newIntStrRaw.length;
|
||||
|
||||
const newIntStr = padStart
|
||||
? newIntStrRaw.padStart(finalIntLength, '0')
|
||||
: newIntStrRaw;
|
||||
|
||||
const prevFormatted = formatNumber(prevNumberRef.current);
|
||||
const [prevIntStrRaw = '', prevDecStrRaw = ''] = prevFormatted.split('.');
|
||||
const prevIntStr = padStart
|
||||
? prevIntStrRaw.padStart(finalIntLength, '0')
|
||||
: prevIntStrRaw;
|
||||
|
||||
const adjustedPrevInt = React.useMemo(() => {
|
||||
return prevIntStr.length > finalIntLength
|
||||
? prevIntStr.slice(-finalIntLength)
|
||||
: prevIntStr.padStart(finalIntLength, '0');
|
||||
}, [prevIntStr, finalIntLength]);
|
||||
|
||||
const adjustedPrevDec = React.useMemo(() => {
|
||||
if (!newDecStrRaw) return '';
|
||||
return prevDecStrRaw.length > newDecStrRaw.length
|
||||
? prevDecStrRaw.slice(0, newDecStrRaw.length)
|
||||
: prevDecStrRaw.padEnd(newDecStrRaw.length, '0');
|
||||
}, [prevDecStrRaw, newDecStrRaw]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isInView || initiallyStable) {
|
||||
prevNumberRef.current = effectiveNumber;
|
||||
}
|
||||
}, [effectiveNumber, isInView, initiallyStable]);
|
||||
|
||||
const intPlaces = React.useMemo(
|
||||
() =>
|
||||
Array.from({ length: finalIntLength }, (_, i) =>
|
||||
Math.pow(10, finalIntLength - i - 1),
|
||||
),
|
||||
[finalIntLength],
|
||||
);
|
||||
const decPlaces = React.useMemo(
|
||||
() =>
|
||||
newDecStrRaw
|
||||
? Array.from({ length: newDecStrRaw.length }, (_, i) =>
|
||||
Math.pow(10, newDecStrRaw.length - i - 1),
|
||||
)
|
||||
: [],
|
||||
[newDecStrRaw],
|
||||
);
|
||||
|
||||
const newDecValue = newDecStrRaw ? parseInt(newDecStrRaw, 10) : 0;
|
||||
const prevDecValue = adjustedPrevDec ? parseInt(adjustedPrevDec, 10) : 0;
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={localRef}
|
||||
data-slot="sliding-number"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{isInView && Number(number) < 0 && (
|
||||
<span style={{ marginRight: '0.25rem' }}>-</span>
|
||||
)}
|
||||
|
||||
{intPlaces.map((place, idx) => {
|
||||
const digitsToRight = intPlaces.length - idx - 1;
|
||||
const isSeparatorPosition =
|
||||
typeof thousandSeparator !== 'undefined' &&
|
||||
digitsToRight > 0 &&
|
||||
digitsToRight % 3 === 0;
|
||||
|
||||
return (
|
||||
<React.Fragment key={`int-${place}`}>
|
||||
<SlidingNumberRoller
|
||||
prevValue={parseInt(adjustedPrevInt, 10)}
|
||||
value={parseInt(newIntStr ?? '0', 10)}
|
||||
place={place}
|
||||
transition={transition}
|
||||
/>
|
||||
{isSeparatorPosition && <span>{thousandSeparator}</span>}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{newDecStrRaw && (
|
||||
<>
|
||||
<span>{decimalSeparator}</span>
|
||||
{decPlaces.map((place) => (
|
||||
<SlidingNumberRoller
|
||||
key={`dec-${place}`}
|
||||
prevValue={prevDecValue}
|
||||
value={newDecValue}
|
||||
place={place}
|
||||
transition={transition}
|
||||
delay={delay}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export { SlidingNumber, type SlidingNumberProps };
|
||||
@@ -1,112 +1,55 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
import {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay as AnimateDialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent as AnimateDialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/animate-ui/primitives/radix/dialog';
|
||||
|
||||
// Drop-in DialogContent that bundles portal + overlay + close button
|
||||
// while delegating animation to animate-ui's spring-based dialog
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof AnimateDialogContent>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
<AnimateDialogOverlay className="fixed inset-0 z-50 bg-black/80" />
|
||||
<AnimateDialogContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogClose>
|
||||
</AnimateDialogContent>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
));
|
||||
DialogContent.displayName = 'DialogContent';
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
const DialogOverlay = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof AnimateDialogOverlay>
|
||||
>((props, ref) => (
|
||||
<AnimateDialogOverlay ref={ref} {...props} />
|
||||
));
|
||||
DialogOverlay.displayName = 'DialogOverlay';
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
@@ -119,4 +62,4 @@ export {
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Switch as AnimateSwitch,
|
||||
SwitchThumb,
|
||||
type SwitchProps as AnimateSwitchProps,
|
||||
} from '@/components/animate-ui/primitives/radix/switch';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
type SwitchProps = Omit<AnimateSwitchProps, 'children'>;
|
||||
|
||||
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<AnimateSwitch
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
{...props}
|
||||
>
|
||||
<SwitchThumb
|
||||
className="pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0"
|
||||
pressedAnimation={{ scale: 1.15 }}
|
||||
/>
|
||||
</AnimateSwitch>
|
||||
)
|
||||
);
|
||||
Switch.displayName = 'Switch';
|
||||
|
||||
export { Switch }
|
||||
export { Switch };
|
||||
|
||||
@@ -1,53 +1,57 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
import {
|
||||
Tabs,
|
||||
TabsList as AnimateTabsList,
|
||||
TabsTrigger as AnimateTabsTrigger,
|
||||
TabsContent as AnimateTabsContent,
|
||||
} from '@/components/animate-ui/primitives/radix/tabs';
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof AnimateTabsList>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
<AnimateTabsList
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
));
|
||||
TabsList.displayName = 'TabsList';
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof AnimateTabsTrigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
<AnimateTabsTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
));
|
||||
TabsTrigger.displayName = 'TabsTrigger';
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof AnimateTabsContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
<AnimateTabsContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
));
|
||||
TabsContent.displayName = 'TabsContent';
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
'use client';
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
import {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipPortal,
|
||||
TooltipContent as AnimateTooltipContent,
|
||||
} from '@/components/animate-ui/primitives/radix/tooltip';
|
||||
|
||||
// Bundles the portal so consumers keep the same <TooltipContent> API
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof AnimateTooltipContent>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
<TooltipPortal>
|
||||
<AnimateTooltipContent
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground origin-[--radix-tooltip-content-transform-origin]',
|
||||
className
|
||||
)}
|
||||
transition={{ type: 'spring', stiffness: 350, damping: 28 }}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
</TooltipPortal>
|
||||
));
|
||||
TooltipContent.displayName = 'TooltipContent';
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
Reference in New Issue
Block a user