diff --git a/components/index.tsx b/components/index.tsx index a238f6d08..9d4efcef8 100644 --- a/components/index.tsx +++ b/components/index.tsx @@ -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; } } diff --git a/components/radix/dialogs/KeyboardShortcutsDialog.tsx b/components/radix/dialogs/KeyboardShortcutsDialog.tsx new file mode 100644 index 000000000..e558177df --- /dev/null +++ b/components/radix/dialogs/KeyboardShortcutsDialog.tsx @@ -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' }, + * ] + * } + * ] + * + * + * ``` + */ + +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 ( +
+ + {description} + +
+ {keys.map((key, index) => ( + + {key} + + ))} +
+
+ ) +} + +/** + * 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' + * + * + * + * + * + * + * ``` + */ +export function KeyboardShortcutsContent({ + shortcuts, + title = 'Keyboard Shortcuts', + description = 'Speed up your workflow with these shortcuts', + icon, +}: Omit) { + return ( +
+ {/* Header */} +
+

+ {icon} + {title} +

+ {description && ( +

+ {description} +

+ )} +
+ + {/* Shortcut categories */} +
+ {shortcuts.map((category, categoryIndex) => ( +
+

+ {category.category} +

+
+ {category.items.map((item, itemIndex) => ( + + ))} +
+ {categoryIndex < shortcuts.length - 1 && ( +
+ )} +
+ ))} +
+
+ ) +} + +/** + * 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 diff --git a/components/radix/index.ts b/components/radix/index.ts index 9004eddc1..8f86c787d 100644 --- a/components/radix/index.ts +++ b/components/radix/index.ts @@ -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' diff --git a/components/vanilla/index.ts b/components/vanilla/index.ts index 7019ae395..6da7b4f2e 100644 --- a/components/vanilla/index.ts +++ b/components/vanilla/index.ts @@ -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' diff --git a/components/vanilla/notifications/index.tsx b/components/vanilla/notifications/index.tsx new file mode 100644 index 000000000..22393cd70 --- /dev/null +++ b/components/vanilla/notifications/index.tsx @@ -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([]) + * + * setNotifications(prev => prev.filter(n => n.id !== id))} + * /> + * + * // With position + * + * ``` + */ + +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 = { + 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 = { + '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 = () => ( + + + +) + +const ErrorIcon = () => ( + + + + + +) + +const WarningIcon = () => ( + + + + + +) + +const InfoIcon = () => ( + + + + + +) + +const CloseIcon = () => ( + + + + +) + +const typeIcons: Record = { + success: SuccessIcon, + error: ErrorIcon, + warning: WarningIcon, + info: InfoIcon, +} + +// ============================================================================= +// COMPONENTS +// ============================================================================= + +/** + * Individual notification item + */ +export const NotificationItem: React.FC = ({ 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 ( +
+
+ +
+
+ {title && ( +

{title}

+ )} +

{message}

+
+ +
+ ) +} + +/** + * Container for notifications - renders all active notifications + */ +export const NotificationContainer: React.FC = ({ + 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 ( +
+ {visibleNotifications.map((notification) => ( +
+ handleClose(notification.id)} + /> +
+ ))} +
+ ) +} + +// ============================================================================= +// 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 + * + */ +export function useNotificationState(initialNotifications: NotificationData[] = []) { + const [notifications, setNotifications] = React.useState(initialNotifications) + + const addNotification = useCallback((notification: Omit) => { + 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 diff --git a/components/vanilla/status-indicators/index.tsx b/components/vanilla/status-indicators/index.tsx new file mode 100644 index 000000000..e80946902 --- /dev/null +++ b/components/vanilla/status-indicators/index.tsx @@ -0,0 +1,328 @@ +/** + * Status Indicator Components + * Generic components for displaying connection/backend status + * + * Usage: + * ```tsx + * // Basic connection status + * + * + * // Backend indicator with custom states + * + * + * // Custom status badge + * } + * /> + * ``` + */ + +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 = { + 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 = { + connected: 'success', + disconnected: 'neutral', + connecting: 'info', + error: 'error', +} + +// ============================================================================= +// ICONS +// ============================================================================= + +const CloudIcon = () => ( + + + +) + +const CloudOffIcon = () => ( + + + +) + +const DatabaseIcon = () => ( + + + + + +) + +const SpinnerIcon = () => ( + + + +) + +// ============================================================================= +// COMPONENTS +// ============================================================================= + +/** + * Generic status badge component + */ +export const StatusBadge: React.FC = ({ + variant, + label, + icon, + showDot, + className, + tooltip, +}) => { + const styles = variantStyles[variant] + + const badge = ( +
+ {icon && {icon}} + {label} + {showDot && ( +
+ ) + + if (tooltip) { + return {badge} + } + + return badge +} + +/** + * Simple connected/disconnected status indicator + */ +export const ConnectionStatus: React.FC = ({ + isConnected, + connectedLabel = 'Connected', + disconnectedLabel = 'Disconnected', + showIcon = true, + className, +}) => { + return ( + : ) : undefined} + showDot={isConnected} + className={className} + /> + ) +} + +/** + * Backend status indicator with multiple states + */ +export const BackendStatus: React.FC = ({ + status, + label, + showDot = true, + connectedTooltip = 'Connected to backend', + disconnectedTooltip = 'Using local storage', + className, +}) => { + const variant = statusToVariant[status] + + const getIcon = () => { + switch (status) { + case 'connected': + return + case 'disconnected': + return + case 'connecting': + return + case 'error': + return + } + } + + 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 ( + + ) +} + +// ============================================================================= +// 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