mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-24 13:54:57 +00:00
feat(components): Add notifications, status indicators, and keyboard shortcuts
New shared components for cross-project reuse: **vanilla/notifications/** - NotificationContainer - renders notification list (Redux-agnostic) - NotificationItem - individual notification with auto-dismiss - useNotificationState - local state hook for notifications - Supports success/error/warning/info types - Configurable position (top-left, top-right, bottom-*, etc.) **vanilla/status-indicators/** - StatusBadge - generic status badge with variants - ConnectionStatus - connected/disconnected indicator - BackendStatus - multi-state backend indicator (connected/local/connecting/error) - All with proper ARIA labels and CSS animations **radix/dialogs/** - KeyboardShortcutsContent - generic shortcuts display - ShortcutRow - single shortcut key + description row - getPlatformModifier - detects ⌘ vs Ctrl - createShortcut - helper for platform-aware shortcuts - Accepts shortcuts as props (not hardcoded) All components follow accessibility best practices with data-testid, ARIA attributes, and reduced-motion support. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -74,12 +74,46 @@ export {
|
||||
type AccessDeniedProps,
|
||||
} from './vanilla/access-denied'
|
||||
|
||||
// Notification components
|
||||
export {
|
||||
NotificationContainer,
|
||||
NotificationItem,
|
||||
useNotificationState,
|
||||
notificationStyles,
|
||||
type NotificationData,
|
||||
type NotificationType,
|
||||
type NotificationPosition,
|
||||
type NotificationContainerProps,
|
||||
type NotificationItemProps,
|
||||
} from './vanilla/notifications'
|
||||
|
||||
// Status indicator components
|
||||
export {
|
||||
StatusBadge,
|
||||
ConnectionStatus,
|
||||
BackendStatus,
|
||||
statusIndicatorStyles,
|
||||
type StatusVariant,
|
||||
type BackendStatusType,
|
||||
type StatusBadgeProps,
|
||||
type ConnectionStatusProps,
|
||||
type BackendStatusProps,
|
||||
} from './vanilla/status-indicators'
|
||||
|
||||
// =============================================================================
|
||||
// RADIX COMPONENTS (Built on @radix-ui primitives)
|
||||
// =============================================================================
|
||||
|
||||
// Add Radix component exports here as they are created
|
||||
// export * from './radix'
|
||||
// Dialog components
|
||||
export {
|
||||
KeyboardShortcutsContent,
|
||||
ShortcutRow,
|
||||
getPlatformModifier,
|
||||
createShortcut,
|
||||
type ShortcutItem,
|
||||
type ShortcutCategory,
|
||||
type KeyboardShortcutsDialogProps,
|
||||
} from './radix/dialogs/KeyboardShortcutsDialog'
|
||||
|
||||
// =============================================================================
|
||||
// FAKEMUI COMPONENTS (Built on @metabuilder/fakemui)
|
||||
@@ -145,11 +179,34 @@ export const allStyles = `
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Notification animations */
|
||||
@keyframes notification-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Status indicator animations */
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
@keyframes status-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Accessibility: respect reduced motion preferences */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.empty-state-animated,
|
||||
.skeleton-animate,
|
||||
.loading-spinner {
|
||||
.loading-spinner,
|
||||
[data-testid^="notification-"],
|
||||
[data-testid="status-badge"] span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
237
components/radix/dialogs/KeyboardShortcutsDialog.tsx
Normal file
237
components/radix/dialogs/KeyboardShortcutsDialog.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Keyboard Shortcuts Dialog
|
||||
* Generic dialog for displaying keyboard shortcuts - accepts shortcuts as props
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const shortcuts = [
|
||||
* {
|
||||
* category: 'Navigation',
|
||||
* items: [
|
||||
* { keys: ['Ctrl', '1'], description: 'Go to Dashboard' },
|
||||
* { keys: ['Ctrl', 'K'], description: 'Search' },
|
||||
* ]
|
||||
* },
|
||||
* {
|
||||
* category: 'Actions',
|
||||
* items: [
|
||||
* { keys: ['Ctrl', 'S'], description: 'Save' },
|
||||
* ]
|
||||
* }
|
||||
* ]
|
||||
*
|
||||
* <KeyboardShortcutsDialog
|
||||
* open={open}
|
||||
* onOpenChange={setOpen}
|
||||
* shortcuts={shortcuts}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ShortcutItem {
|
||||
/** Key combination (e.g., ['Ctrl', 'S'] or ['⌘', 'Shift', 'G']) */
|
||||
keys: string[]
|
||||
/** Description of what the shortcut does */
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface ShortcutCategory {
|
||||
/** Category name (e.g., 'Navigation', 'Actions', 'Editor') */
|
||||
category: string
|
||||
/** Shortcuts in this category */
|
||||
items: ShortcutItem[]
|
||||
}
|
||||
|
||||
export interface KeyboardShortcutsDialogProps {
|
||||
/** Whether dialog is open */
|
||||
open: boolean
|
||||
/** Called when open state changes */
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Grouped shortcuts to display */
|
||||
shortcuts: ShortcutCategory[]
|
||||
/** Dialog title */
|
||||
title?: string
|
||||
/** Dialog description */
|
||||
description?: string
|
||||
/** Icon component to show in header */
|
||||
icon?: React.ReactNode
|
||||
/** Custom className for dialog content */
|
||||
className?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Single shortcut row showing keys and description
|
||||
*/
|
||||
export function ShortcutRow({ keys, description }: ShortcutItem) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 0',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'var(--color-muted-foreground, #666)',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
{keys.map((key, index) => (
|
||||
<kbd
|
||||
key={index}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
backgroundColor: 'var(--color-muted, #f1f1f1)',
|
||||
border: '1px solid var(--color-border, #e0e0e0)',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard Shortcuts Dialog - generic version that accepts shortcuts as props
|
||||
*
|
||||
* Note: This is a vanilla React component. For Radix Dialog integration,
|
||||
* wrap this content in your project's Dialog components:
|
||||
*
|
||||
* ```tsx
|
||||
* import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
*
|
||||
* <Dialog open={open} onOpenChange={onOpenChange}>
|
||||
* <DialogContent>
|
||||
* <KeyboardShortcutsContent shortcuts={shortcuts} />
|
||||
* </DialogContent>
|
||||
* </Dialog>
|
||||
* ```
|
||||
*/
|
||||
export function KeyboardShortcutsContent({
|
||||
shortcuts,
|
||||
title = 'Keyboard Shortcuts',
|
||||
description = 'Speed up your workflow with these shortcuts',
|
||||
icon,
|
||||
}: Omit<KeyboardShortcutsDialogProps, 'open' | 'onOpenChange'>) {
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h2
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{title}
|
||||
</h2>
|
||||
{description && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'var(--color-muted-foreground, #666)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shortcut categories */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
{shortcuts.map((category, categoryIndex) => (
|
||||
<div key={category.category}>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
{category.category}
|
||||
</h3>
|
||||
<div>
|
||||
{category.items.map((item, itemIndex) => (
|
||||
<ShortcutRow key={itemIndex} {...item} />
|
||||
))}
|
||||
</div>
|
||||
{categoryIndex < shortcuts.length - 1 && (
|
||||
<hr
|
||||
style={{
|
||||
border: 'none',
|
||||
borderTop: '1px solid var(--color-border, #e0e0e0)',
|
||||
marginTop: '16px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to detect platform and return appropriate modifier key
|
||||
*/
|
||||
export function getPlatformModifier(): string {
|
||||
if (typeof navigator !== 'undefined' && navigator.platform?.includes('Mac')) {
|
||||
return '⌘'
|
||||
}
|
||||
return 'Ctrl'
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create shortcuts with platform-aware modifier
|
||||
*/
|
||||
export function createShortcut(
|
||||
modifiers: ('ctrl' | 'shift' | 'alt')[],
|
||||
key: string,
|
||||
description: string
|
||||
): ShortcutItem {
|
||||
const ctrlKey = getPlatformModifier()
|
||||
const keys = modifiers.map((mod) => {
|
||||
switch (mod) {
|
||||
case 'ctrl':
|
||||
return ctrlKey
|
||||
case 'shift':
|
||||
return 'Shift'
|
||||
case 'alt':
|
||||
return typeof navigator !== 'undefined' && navigator.platform?.includes('Mac')
|
||||
? '⌥'
|
||||
: 'Alt'
|
||||
}
|
||||
})
|
||||
keys.push(key)
|
||||
|
||||
return { keys, description }
|
||||
}
|
||||
|
||||
export default KeyboardShortcutsContent
|
||||
@@ -7,7 +7,18 @@
|
||||
* Add Radix-based components here as they are created.
|
||||
*/
|
||||
|
||||
// Placeholder exports - add Radix components as they are created
|
||||
// Dialog components
|
||||
export {
|
||||
KeyboardShortcutsContent,
|
||||
ShortcutRow,
|
||||
getPlatformModifier,
|
||||
createShortcut,
|
||||
type ShortcutItem,
|
||||
type ShortcutCategory,
|
||||
type KeyboardShortcutsDialogProps,
|
||||
} from './dialogs/KeyboardShortcutsDialog'
|
||||
|
||||
// Placeholder exports - add more Radix components as they are created
|
||||
// Example:
|
||||
// export { Dialog, DialogContent, DialogHeader, DialogFooter } from './dialog'
|
||||
// export { Button } from './button'
|
||||
|
||||
@@ -65,3 +65,29 @@ export {
|
||||
accessDeniedStyles,
|
||||
type AccessDeniedProps,
|
||||
} from './access-denied'
|
||||
|
||||
// Notification components
|
||||
export {
|
||||
NotificationContainer,
|
||||
NotificationItem,
|
||||
useNotificationState,
|
||||
notificationStyles,
|
||||
type NotificationData,
|
||||
type NotificationType,
|
||||
type NotificationPosition,
|
||||
type NotificationContainerProps,
|
||||
type NotificationItemProps,
|
||||
} from './notifications'
|
||||
|
||||
// Status indicator components
|
||||
export {
|
||||
StatusBadge,
|
||||
ConnectionStatus,
|
||||
BackendStatus,
|
||||
statusIndicatorStyles,
|
||||
type StatusVariant,
|
||||
type BackendStatusType,
|
||||
type StatusBadgeProps,
|
||||
type ConnectionStatusProps,
|
||||
type BackendStatusProps,
|
||||
} from './status-indicators'
|
||||
|
||||
334
components/vanilla/notifications/index.tsx
Normal file
334
components/vanilla/notifications/index.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Notification Components
|
||||
* Generic notification system - framework agnostic (no Redux dependency)
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* // Basic usage with local state
|
||||
* const [notifications, setNotifications] = useState<NotificationData[]>([])
|
||||
*
|
||||
* <NotificationContainer
|
||||
* notifications={notifications}
|
||||
* onClose={(id) => setNotifications(prev => prev.filter(n => n.id !== id))}
|
||||
* />
|
||||
*
|
||||
* // With position
|
||||
* <NotificationContainer
|
||||
* notifications={notifications}
|
||||
* onClose={handleClose}
|
||||
* position="top-right"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { useEffect, useCallback } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type NotificationType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
export type NotificationPosition =
|
||||
| 'top-left'
|
||||
| 'top-center'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-center'
|
||||
| 'bottom-right'
|
||||
|
||||
export interface NotificationData {
|
||||
id: string
|
||||
type: NotificationType
|
||||
message: string
|
||||
title?: string
|
||||
duration?: number // ms, 0 = no auto-dismiss
|
||||
}
|
||||
|
||||
export interface NotificationContainerProps {
|
||||
/** Array of notifications to display */
|
||||
notifications: NotificationData[]
|
||||
/** Called when a notification should be removed */
|
||||
onClose: (id: string) => void
|
||||
/** Position on screen */
|
||||
position?: NotificationPosition
|
||||
/** Maximum notifications to show at once */
|
||||
maxVisible?: number
|
||||
/** Custom className for container */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface NotificationItemProps {
|
||||
notification: NotificationData
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STYLES
|
||||
// =============================================================================
|
||||
|
||||
const typeStyles: Record<NotificationType, { bg: string; border: string; text: string }> = {
|
||||
success: {
|
||||
bg: 'rgba(46, 125, 50, 0.1)',
|
||||
border: 'var(--color-success, #2e7d32)',
|
||||
text: 'var(--color-success, #2e7d32)',
|
||||
},
|
||||
error: {
|
||||
bg: 'rgba(211, 47, 47, 0.1)',
|
||||
border: 'var(--color-error, #d32f2f)',
|
||||
text: 'var(--color-error, #d32f2f)',
|
||||
},
|
||||
warning: {
|
||||
bg: 'rgba(245, 127, 0, 0.1)',
|
||||
border: 'var(--color-warning, #f57f00)',
|
||||
text: 'var(--color-warning, #f57f00)',
|
||||
},
|
||||
info: {
|
||||
bg: 'rgba(2, 136, 209, 0.1)',
|
||||
border: 'var(--color-info, #0288d1)',
|
||||
text: 'var(--color-info, #0288d1)',
|
||||
},
|
||||
}
|
||||
|
||||
const positionStyles: Record<NotificationPosition, React.CSSProperties> = {
|
||||
'top-left': { top: 16, left: 16 },
|
||||
'top-center': { top: 16, left: '50%', transform: 'translateX(-50%)' },
|
||||
'top-right': { top: 16, right: 16 },
|
||||
'bottom-left': { bottom: 16, left: 16 },
|
||||
'bottom-center': { bottom: 16, left: '50%', transform: 'translateX(-50%)' },
|
||||
'bottom-right': { bottom: 16, right: 16 },
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ICONS
|
||||
// =============================================================================
|
||||
|
||||
const SuccessIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ErrorIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const WarningIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const InfoIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CloseIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const typeIcons: Record<NotificationType, React.FC> = {
|
||||
success: SuccessIcon,
|
||||
error: ErrorIcon,
|
||||
warning: WarningIcon,
|
||||
info: InfoIcon,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Individual notification item
|
||||
*/
|
||||
export const NotificationItem: React.FC<NotificationItemProps> = ({ notification, onClose }) => {
|
||||
const { type, message, title, duration } = notification
|
||||
const styles = typeStyles[type]
|
||||
const Icon = typeIcons[type]
|
||||
|
||||
useEffect(() => {
|
||||
if (duration && duration > 0) {
|
||||
const timer = setTimeout(onClose, duration)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [duration, onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid={`notification-${notification.id}`}
|
||||
data-notification-type={type}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: styles.bg,
|
||||
borderLeft: `4px solid ${styles.border}`,
|
||||
color: styles.text,
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'flex-start',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
minWidth: '280px',
|
||||
maxWidth: '400px',
|
||||
animation: 'notification-slide-in 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
<div style={{ flexShrink: 0, marginTop: 2 }}>
|
||||
<Icon />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{title && (
|
||||
<p style={{ fontWeight: 600, marginBottom: 4, fontSize: '14px' }}>{title}</p>
|
||||
)}
|
||||
<p style={{ fontSize: '14px', lineHeight: 1.5 }}>{message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close notification"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
color: 'inherit',
|
||||
opacity: 0.7,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for notifications - renders all active notifications
|
||||
*/
|
||||
export const NotificationContainer: React.FC<NotificationContainerProps> = ({
|
||||
notifications,
|
||||
onClose,
|
||||
position = 'top-right',
|
||||
maxVisible = 5,
|
||||
className,
|
||||
}) => {
|
||||
const handleClose = useCallback(
|
||||
(id: string) => {
|
||||
onClose(id)
|
||||
},
|
||||
[onClose]
|
||||
)
|
||||
|
||||
const visibleNotifications = notifications.slice(0, maxVisible)
|
||||
|
||||
if (visibleNotifications.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
aria-label="Notifications"
|
||||
data-testid="notification-container"
|
||||
className={className}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
flexDirection: position.startsWith('bottom') ? 'column-reverse' : 'column',
|
||||
gap: '8px',
|
||||
pointerEvents: 'none',
|
||||
...positionStyles[position],
|
||||
}}
|
||||
>
|
||||
{visibleNotifications.map((notification) => (
|
||||
<div key={notification.id} style={{ pointerEvents: 'auto' }}>
|
||||
<NotificationItem
|
||||
notification={notification}
|
||||
onClose={() => handleClose(notification.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STYLES (CSS-in-JS keyframes)
|
||||
// =============================================================================
|
||||
|
||||
export const notificationStyles = `
|
||||
@keyframes notification-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-testid^="notification-"] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// =============================================================================
|
||||
// HOOK FOR NOTIFICATION STATE MANAGEMENT
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Hook for managing notification state (optional - can use Redux instead)
|
||||
*
|
||||
* @example
|
||||
* const { notifications, addNotification, removeNotification, clearAll } = useNotificationState()
|
||||
*
|
||||
* // Add a notification
|
||||
* addNotification({ type: 'success', message: 'Saved!', duration: 3000 })
|
||||
*
|
||||
* // Render
|
||||
* <NotificationContainer notifications={notifications} onClose={removeNotification} />
|
||||
*/
|
||||
export function useNotificationState(initialNotifications: NotificationData[] = []) {
|
||||
const [notifications, setNotifications] = React.useState<NotificationData[]>(initialNotifications)
|
||||
|
||||
const addNotification = useCallback((notification: Omit<NotificationData, 'id'>) => {
|
||||
const id = `notification-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
setNotifications((prev) => [...prev, { ...notification, id }])
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const removeNotification = useCallback((id: string) => {
|
||||
setNotifications((prev) => prev.filter((n) => n.id !== id))
|
||||
}, [])
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setNotifications([])
|
||||
}, [])
|
||||
|
||||
return {
|
||||
notifications,
|
||||
addNotification,
|
||||
removeNotification,
|
||||
clearAll,
|
||||
}
|
||||
}
|
||||
|
||||
export default NotificationContainer
|
||||
328
components/vanilla/status-indicators/index.tsx
Normal file
328
components/vanilla/status-indicators/index.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Status Indicator Components
|
||||
* Generic components for displaying connection/backend status
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* // Basic connection status
|
||||
* <ConnectionStatus isConnected={true} label="API" />
|
||||
*
|
||||
* // Backend indicator with custom states
|
||||
* <BackendStatus
|
||||
* status="connected"
|
||||
* label="Flask Backend"
|
||||
* showDot={true}
|
||||
* />
|
||||
*
|
||||
* // Custom status badge
|
||||
* <StatusBadge
|
||||
* variant="success"
|
||||
* label="Online"
|
||||
* icon={<CheckIcon />}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type StatusVariant = 'success' | 'error' | 'warning' | 'info' | 'neutral'
|
||||
|
||||
export type BackendStatusType = 'connected' | 'disconnected' | 'connecting' | 'error'
|
||||
|
||||
export interface StatusBadgeProps {
|
||||
/** Visual variant */
|
||||
variant: StatusVariant
|
||||
/** Label text */
|
||||
label: string
|
||||
/** Optional icon (renders before label) */
|
||||
icon?: React.ReactNode
|
||||
/** Show pulsing dot indicator */
|
||||
showDot?: boolean
|
||||
/** Additional className */
|
||||
className?: string
|
||||
/** Tooltip text */
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export interface ConnectionStatusProps {
|
||||
/** Whether connected */
|
||||
isConnected: boolean
|
||||
/** Label for connected state */
|
||||
connectedLabel?: string
|
||||
/** Label for disconnected state */
|
||||
disconnectedLabel?: string
|
||||
/** Show icon */
|
||||
showIcon?: boolean
|
||||
/** Additional className */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface BackendStatusProps {
|
||||
/** Current status */
|
||||
status: BackendStatusType
|
||||
/** Backend name/label */
|
||||
label?: string
|
||||
/** Show pulsing activity dot */
|
||||
showDot?: boolean
|
||||
/** Tooltip for connected state */
|
||||
connectedTooltip?: string
|
||||
/** Tooltip for disconnected state */
|
||||
disconnectedTooltip?: string
|
||||
/** Additional className */
|
||||
className?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STYLES
|
||||
// =============================================================================
|
||||
|
||||
const variantStyles: Record<StatusVariant, { bg: string; border: string; text: string; dot: string }> = {
|
||||
success: {
|
||||
bg: 'rgba(46, 125, 50, 0.1)',
|
||||
border: 'rgba(46, 125, 50, 0.3)',
|
||||
text: 'var(--color-success, #2e7d32)',
|
||||
dot: 'var(--color-success, #2e7d32)',
|
||||
},
|
||||
error: {
|
||||
bg: 'rgba(211, 47, 47, 0.1)',
|
||||
border: 'rgba(211, 47, 47, 0.3)',
|
||||
text: 'var(--color-error, #d32f2f)',
|
||||
dot: 'var(--color-error, #d32f2f)',
|
||||
},
|
||||
warning: {
|
||||
bg: 'rgba(245, 127, 0, 0.1)',
|
||||
border: 'rgba(245, 127, 0, 0.3)',
|
||||
text: 'var(--color-warning, #f57f00)',
|
||||
dot: 'var(--color-warning, #f57f00)',
|
||||
},
|
||||
info: {
|
||||
bg: 'rgba(2, 136, 209, 0.1)',
|
||||
border: 'rgba(2, 136, 209, 0.3)',
|
||||
text: 'var(--color-info, #0288d1)',
|
||||
dot: 'var(--color-info, #0288d1)',
|
||||
},
|
||||
neutral: {
|
||||
bg: 'rgba(128, 128, 128, 0.1)',
|
||||
border: 'rgba(128, 128, 128, 0.3)',
|
||||
text: 'var(--color-muted, #666)',
|
||||
dot: 'var(--color-muted, #666)',
|
||||
},
|
||||
}
|
||||
|
||||
const statusToVariant: Record<BackendStatusType, StatusVariant> = {
|
||||
connected: 'success',
|
||||
disconnected: 'neutral',
|
||||
connecting: 'info',
|
||||
error: 'error',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ICONS
|
||||
// =============================================================================
|
||||
|
||||
const CloudIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CloudOffIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4c-1.48 0-2.85.43-4.01 1.17l1.46 1.46C10.21 6.23 11.08 6 12 6c3.04 0 5.5 2.46 5.5 5.5v.5H19c1.66 0 3 1.34 3 3 0 1.13-.64 2.11-1.56 2.62l1.45 1.45C23.16 18.16 24 16.68 24 15c0-2.64-2.05-4.78-4.65-4.96zM3 5.27l2.75 2.74C2.56 8.15 0 10.77 0 14c0 3.31 2.69 6 6 6h11.73l2 2L21 20.73 4.27 4 3 5.27zM7.73 10l8 8H6c-2.21 0-4-1.79-4-4s1.79-4 4-4h1.73z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const DatabaseIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SpinnerIcon = () => (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ animation: 'status-spin 1s linear infinite' }}
|
||||
>
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generic status badge component
|
||||
*/
|
||||
export const StatusBadge: React.FC<StatusBadgeProps> = ({
|
||||
variant,
|
||||
label,
|
||||
icon,
|
||||
showDot,
|
||||
className,
|
||||
tooltip,
|
||||
}) => {
|
||||
const styles = variantStyles[variant]
|
||||
|
||||
const badge = (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={`Status: ${label}`}
|
||||
data-testid="status-badge"
|
||||
className={className}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '9999px',
|
||||
backgroundColor: styles.bg,
|
||||
border: `1px solid ${styles.border}`,
|
||||
color: styles.text,
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{icon && <span style={{ display: 'flex', alignItems: 'center' }}>{icon}</span>}
|
||||
<span>{label}</span>
|
||||
{showDot && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: styles.dot,
|
||||
animation: 'status-pulse 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return <span title={tooltip}>{badge}</span>
|
||||
}
|
||||
|
||||
return badge
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple connected/disconnected status indicator
|
||||
*/
|
||||
export const ConnectionStatus: React.FC<ConnectionStatusProps> = ({
|
||||
isConnected,
|
||||
connectedLabel = 'Connected',
|
||||
disconnectedLabel = 'Disconnected',
|
||||
showIcon = true,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<StatusBadge
|
||||
variant={isConnected ? 'success' : 'neutral'}
|
||||
label={isConnected ? connectedLabel : disconnectedLabel}
|
||||
icon={showIcon ? (isConnected ? <CloudIcon /> : <CloudOffIcon />) : undefined}
|
||||
showDot={isConnected}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend status indicator with multiple states
|
||||
*/
|
||||
export const BackendStatus: React.FC<BackendStatusProps> = ({
|
||||
status,
|
||||
label,
|
||||
showDot = true,
|
||||
connectedTooltip = 'Connected to backend',
|
||||
disconnectedTooltip = 'Using local storage',
|
||||
className,
|
||||
}) => {
|
||||
const variant = statusToVariant[status]
|
||||
|
||||
const getIcon = () => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return <CloudIcon />
|
||||
case 'disconnected':
|
||||
return <DatabaseIcon />
|
||||
case 'connecting':
|
||||
return <SpinnerIcon />
|
||||
case 'error':
|
||||
return <CloudOffIcon />
|
||||
}
|
||||
}
|
||||
|
||||
const getLabel = () => {
|
||||
if (label) return label
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'Connected'
|
||||
case 'disconnected':
|
||||
return 'Local'
|
||||
case 'connecting':
|
||||
return 'Connecting...'
|
||||
case 'error':
|
||||
return 'Error'
|
||||
}
|
||||
}
|
||||
|
||||
const getTooltip = () => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return connectedTooltip
|
||||
case 'disconnected':
|
||||
return disconnectedTooltip
|
||||
case 'connecting':
|
||||
return 'Establishing connection...'
|
||||
case 'error':
|
||||
return 'Connection failed'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusBadge
|
||||
variant={variant}
|
||||
label={getLabel()}
|
||||
icon={getIcon()}
|
||||
showDot={showDot && status === 'connected'}
|
||||
tooltip={getTooltip()}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STYLES (CSS-in-JS keyframes)
|
||||
// =============================================================================
|
||||
|
||||
export const statusIndicatorStyles = `
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes status-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-testid="status-badge"] span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default StatusBadge
|
||||
Reference in New Issue
Block a user