diff --git a/src/components/Config/EditTypeInline.tsx b/src/components/Config/EditTypeInline.tsx new file mode 100644 index 0000000..5b5b2b4 --- /dev/null +++ b/src/components/Config/EditTypeInline.tsx @@ -0,0 +1,127 @@ +import { useState, useEffect, useRef, KeyboardEvent } from 'react'; +import SaveIcon from '@mui/icons-material/Save'; +import TypeFormFields from './TypeFormFields'; +import type { NodeTypeConfig, NodeShape } from '../../types'; + +/** + * EditTypeInline - Inline edit view that replaces the right column + * + * Features: + * - Replaces management list in right column when editing + * - Reuses TypeFormFields + * - Save/Cancel actions + * - Keyboard accessible (Cmd/Ctrl+Enter to save, Escape to cancel) + */ + +interface Props { + type: NodeTypeConfig; + onSave: ( + id: string, + updates: { + label: string; + color: string; + shape: NodeShape; + icon?: string; + description?: string; + } + ) => void; + onCancel: () => void; +} + +const EditTypeInline = ({ type, onSave, onCancel }: Props) => { + const [name, setName] = useState(''); + const [color, setColor] = useState('#6366f1'); + const [shape, setShape] = useState('rectangle'); + const [icon, setIcon] = useState(''); + const [description, setDescription] = useState(''); + + const nameInputRef = useRef(null); + + // Sync state with type prop + useEffect(() => { + if (type) { + setName(type.label); + setColor(type.color); + setShape(type.shape || 'rectangle'); + setIcon(type.icon || ''); + setDescription(type.description || ''); + } + }, [type]); + + const handleSave = () => { + if (!name.trim()) { + nameInputRef.current?.focus(); + return; + } + + onSave(type.id, { + label: name.trim(), + color, + shape, + icon: icon || undefined, + description: description.trim() || undefined, + }); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + + return ( +
+ {/* Form Fields */} +
+ +
+ + {/* Actions */} +
+
+ + +
+ + {/* Keyboard Shortcut Hint */} +
+ + {navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl'}+Enter + {' '} + to save, Esc to cancel +
+
+
+ ); +}; + +export default EditTypeInline; diff --git a/src/components/Config/IconPopover.tsx b/src/components/Config/IconPopover.tsx new file mode 100644 index 0000000..30a44b9 --- /dev/null +++ b/src/components/Config/IconPopover.tsx @@ -0,0 +1,248 @@ +import { useState, useRef, useEffect, KeyboardEvent } from 'react'; +import PersonIcon from '@mui/icons-material/Person'; +import GroupIcon from '@mui/icons-material/Group'; +import BusinessIcon from '@mui/icons-material/Business'; +import ComputerIcon from '@mui/icons-material/Computer'; +import CloudIcon from '@mui/icons-material/Cloud'; +import StorageIcon from '@mui/icons-material/Storage'; +import DevicesIcon from '@mui/icons-material/Devices'; +import AccountTreeIcon from '@mui/icons-material/AccountTree'; +import CategoryIcon from '@mui/icons-material/Category'; +import LightbulbIcon from '@mui/icons-material/Lightbulb'; +import WorkIcon from '@mui/icons-material/Work'; +import SchoolIcon from '@mui/icons-material/School'; +import LocalHospitalIcon from '@mui/icons-material/LocalHospital'; +import AccountBalanceIcon from '@mui/icons-material/AccountBalance'; +import StoreIcon from '@mui/icons-material/Store'; +import FactoryIcon from '@mui/icons-material/Factory'; +import EngineeringIcon from '@mui/icons-material/Engineering'; +import ScienceIcon from '@mui/icons-material/Science'; +import PublicIcon from '@mui/icons-material/Public'; +import LocationCityIcon from '@mui/icons-material/LocationCity'; +import CloseIcon from '@mui/icons-material/Close'; + +/** + * IconPopover - Floating popover icon picker + * + * Features: + * - Button trigger with current icon preview + * - Floating popover (no nested scrolling) + * - Grid display of available icons + * - Visual selection feedback + * - Keyboard accessible (Escape to close, Arrow keys to navigate) + * - Click outside to close + * - ARIA compliant + */ + +interface Props { + selectedIcon?: string; + onSelect: (iconName: string) => void; +} + +// Available icons with their names +const availableIcons = [ + { name: 'Person', component: PersonIcon }, + { name: 'Group', component: GroupIcon }, + { name: 'Business', component: BusinessIcon }, + { name: 'Computer', component: ComputerIcon }, + { name: 'Cloud', component: CloudIcon }, + { name: 'Storage', component: StorageIcon }, + { name: 'Devices', component: DevicesIcon }, + { name: 'AccountTree', component: AccountTreeIcon }, + { name: 'Category', component: CategoryIcon }, + { name: 'Lightbulb', component: LightbulbIcon }, + { name: 'Work', component: WorkIcon }, + { name: 'School', component: SchoolIcon }, + { name: 'LocalHospital', component: LocalHospitalIcon }, + { name: 'AccountBalance', component: AccountBalanceIcon }, + { name: 'Store', component: StoreIcon }, + { name: 'Factory', component: FactoryIcon }, + { name: 'Engineering', component: EngineeringIcon }, + { name: 'Science', component: ScienceIcon }, + { name: 'Public', component: PublicIcon }, + { name: 'LocationCity', component: LocationCityIcon }, +]; + +const IconPopover = ({ selectedIcon, onSelect }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); + const buttonRef = useRef(null); + const popoverRef = useRef(null); + + // Find the selected icon component + const selectedIconObj = availableIcons.find((icon) => icon.name === selectedIcon); + const SelectedIconComponent = selectedIconObj?.component; + + // Close on click outside + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + // Close on Escape key + useEffect(() => { + if (!isOpen) return; + + const handleEscape = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false); + buttonRef.current?.focus(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen]); + + const handleSelectIcon = (iconName: string) => { + onSelect(iconName); + setIsOpen(false); + buttonRef.current?.focus(); + }; + + const handleKeyDown = (e: KeyboardEvent, iconName: string, index: number) => { + const gridCols = 8; + const totalItems = availableIcons.length + 1; // +1 for "no icon" option + + switch (e.key) { + case 'Enter': + case ' ': + e.preventDefault(); + handleSelectIcon(iconName); + break; + case 'ArrowRight': + e.preventDefault(); + setFocusedIndex(Math.min(index + 1, totalItems - 1)); + break; + case 'ArrowLeft': + e.preventDefault(); + setFocusedIndex(Math.max(index - 1, 0)); + break; + case 'ArrowDown': + e.preventDefault(); + setFocusedIndex(Math.min(index + gridCols, totalItems - 1)); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusedIndex(Math.max(index - gridCols, 0)); + break; + } + }; + + // Auto-focus when navigating with keyboard + useEffect(() => { + if (focusedIndex >= 0 && popoverRef.current) { + const buttons = popoverRef.current.querySelectorAll('button'); + const button = buttons[focusedIndex] as HTMLButtonElement; + button?.focus(); + } + }, [focusedIndex]); + + return ( +
+ {/* Trigger Button */} + + + {/* Popover */} + {isOpen && ( +
+
+ {/* No icon option */} + + + {/* Icon options */} + {availableIcons.map((icon, index) => { + const IconComponent = icon.component; + const actualIndex = index + 1; // +1 because "no icon" is at index 0 + return ( + + ); + })} +
+
+ )} +
+ ); +}; + +export default IconPopover; diff --git a/src/components/Config/TypeFormFields.tsx b/src/components/Config/TypeFormFields.tsx new file mode 100644 index 0000000..8547ab0 --- /dev/null +++ b/src/components/Config/TypeFormFields.tsx @@ -0,0 +1,121 @@ +import { KeyboardEvent } from 'react'; +import IconPopover from './IconPopover'; +import ShapeSelector from './ShapeSelector'; +import type { NodeShape } from '../../types'; + +/** + * TypeFormFields - Reusable form fields for add/edit actor types + * + * Features: + * - All fields visible + * - Keyboard accessible + * - Consistent between add and edit modes + */ + +interface Props { + name: string; + color: string; + shape: NodeShape; + icon: string; + description: string; + onNameChange: (value: string) => void; + onColorChange: (value: string) => void; + onShapeChange: (value: NodeShape) => void; + onIconChange: (value: string) => void; + onDescriptionChange: (value: string) => void; + onKeyDown?: (e: KeyboardEvent) => void; + nameInputRef?: React.RefObject; + autoFocusName?: boolean; +} + +const TypeFormFields = ({ + name, + color, + shape, + icon, + description, + onNameChange, + onColorChange, + onShapeChange, + onIconChange, + onDescriptionChange, + onKeyDown, + nameInputRef, + autoFocusName = false, +}: Props) => { + + return ( +
+ {/* Name, Color, and Icon - Single row */} +
+
+ {/* Name */} +
+ + onNameChange(e.target.value)} + onKeyDown={onKeyDown} + placeholder="e.g., Department" + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + aria-required="true" + autoFocus={autoFocusName} + /> +
+ + {/* Color */} +
+ + onColorChange(e.target.value)} + onKeyDown={onKeyDown} + className="h-8 w-full rounded cursor-pointer border border-gray-300" + aria-label="Color picker" + /> +
+ + {/* Icon */} +
+ + +
+
+
+ + {/* Shape Selector */} + + + {/* Description */} +
+ +