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:
2026-02-02 09:08:48 +00:00
parent d0dbb64ebd
commit 3ad0ca1bc7
6 changed files with 997 additions and 4 deletions

View File

@@ -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;
}
}

View 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

View File

@@ -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'

View File

@@ -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'

View 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

View 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