feat: redesign relation type configuration with improved UX

Updates the relation type configuration to match the new actor type UX
with a modern two-column layout and streamlined workflows.

Changes to relation type configuration:
- Two-column layout: quick add (left) + management list (right)
- Inline editing replaces the right column when editing
- Single-row layout for name, color, and line style fields
- Line style preview with visual feedback
- White background cards with click-to-edit interaction
- Always-visible duplicate and delete buttons
- Full-width edit mode for better focus
- Toast notifications for all actions
- Keyboard shortcuts (Cmd/Ctrl+Enter to add/save, Esc to cancel)
- Removed old EdgeTypeForm in favor of modular components

Changes to actor type configuration:
- Updated TypeManagementList to match EdgeTypeManagementList styling
- White background cards with click-to-edit
- Always-visible action buttons (no hover-to-reveal)
- Simplified implementation (removed complex menu states)
- Consistent appearance across both type configurations

New components:
- EdgeTypeFormFields: Reusable form with compact layout
- EditEdgeTypeInline: Inline editing component
- QuickAddEdgeTypeForm: Quick add interface
- EdgeTypeManagementList: Scrollable list with actions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-16 19:47:12 +02:00
parent bc6ffb5bc3
commit a4db401ff7
12 changed files with 2837 additions and 494 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,21 @@
import { useState } from 'react'; import { useState } from 'react';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory'; import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import EdgeTypeForm from './EdgeTypeForm';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
import { useToastStore } from '../../stores/toastStore';
import QuickAddEdgeTypeForm from './QuickAddEdgeTypeForm';
import EdgeTypeManagementList from './EdgeTypeManagementList';
import EditEdgeTypeInline from './EditEdgeTypeInline';
import type { EdgeTypeConfig, EdgeDirectionality } from '../../types'; import type { EdgeTypeConfig, EdgeDirectionality } from '../../types';
/** /**
* EdgeTypeConfig - Modal for managing relation/edge types * EdgeTypeConfig - Modal for managing relation/edge types
* *
* Features: * Features:
* - Add new edge types with custom name, color, and style * - Two-column layout: quick add (left) + management/edit (right)
* - Edit existing edge types * - Inline editing replaces right column
* - Delete edge types * - Compact card-based management list
* - Style selector (solid, dashed, dotted) * - Toast notifications for actions
* - Full keyboard accessibility
*/ */
interface Props { interface Props {
@ -22,51 +26,38 @@ interface Props {
const EdgeTypeConfigModal = ({ isOpen, onClose }: Props) => { const EdgeTypeConfigModal = ({ isOpen, onClose }: Props) => {
const { edgeTypes, addEdgeType, updateEdgeType, deleteEdgeType } = useGraphWithHistory(); const { edgeTypes, addEdgeType, updateEdgeType, deleteEdgeType } = useGraphWithHistory();
const { confirm, ConfirmDialogComponent } = useConfirm(); const { confirm, ConfirmDialogComponent } = useConfirm();
const { showToast } = useToastStore();
const [newTypeName, setNewTypeName] = useState(''); const [editingType, setEditingType] = useState<EdgeTypeConfig | null>(null);
const [newTypeColor, setNewTypeColor] = useState('#6366f1');
const [newTypeStyle, setNewTypeStyle] = useState<'solid' | 'dashed' | 'dotted'>('solid');
const [newTypeDirectionality, setNewTypeDirectionality] = useState<EdgeDirectionality>('directed');
// Editing state const handleAddType = (type: {
const [editingId, setEditingId] = useState<string | null>(null); label: string;
const [editLabel, setEditLabel] = useState(''); color: string;
const [editColor, setEditColor] = useState(''); style: 'solid' | 'dashed' | 'dotted';
const [editStyle, setEditStyle] = useState<'solid' | 'dashed' | 'dotted'>('solid'); defaultDirectionality: EdgeDirectionality;
const [editDirectionality, setEditDirectionality] = useState<EdgeDirectionality>('directed'); }) => {
const id = type.label.toLowerCase().replace(/\s+/g, '-');
const handleAddType = () => {
if (!newTypeName.trim()) {
alert('Please enter a name for the relation type');
return;
}
const id = newTypeName.toLowerCase().replace(/\s+/g, '-');
// Check if ID already exists // Check if ID already exists
if (edgeTypes.some(et => et.id === id)) { if (edgeTypes.some(et => et.id === id)) {
alert('A relation type with this name already exists'); showToast('A relation type with this name already exists', 'error');
return; return;
} }
const newType: EdgeTypeConfig = { const newType: EdgeTypeConfig = {
id, id,
label: newTypeName.trim(), label: type.label,
color: newTypeColor, color: type.color,
style: newTypeStyle, style: type.style,
defaultDirectionality: newTypeDirectionality, defaultDirectionality: type.defaultDirectionality,
}; };
addEdgeType(newType); addEdgeType(newType);
showToast(`Relation type "${type.label}" created`, 'success');
// Reset form
setNewTypeName('');
setNewTypeColor('#6366f1');
setNewTypeStyle('solid');
setNewTypeDirectionality('directed');
}; };
const handleDeleteType = async (id: string) => { const handleDeleteType = async (id: string) => {
const type = edgeTypes.find(t => t.id === id);
const confirmed = await confirm({ const confirmed = await confirm({
title: 'Delete Relation Type', title: 'Delete Relation Type',
message: 'Are you sure you want to delete this relation type? This action cannot be undone.', message: 'Are you sure you want to delete this relation type? This action cannot be undone.',
@ -75,176 +66,144 @@ const EdgeTypeConfigModal = ({ isOpen, onClose }: Props) => {
}); });
if (confirmed) { if (confirmed) {
deleteEdgeType(id); deleteEdgeType(id);
showToast(`Relation type "${type?.label}" deleted`, 'success');
} }
}; };
const handleEditType = (type: EdgeTypeConfig) => { const handleEditType = (type: EdgeTypeConfig) => {
setEditingId(type.id); setEditingType(type);
setEditLabel(type.label);
setEditColor(type.color);
setEditStyle(type.style || 'solid');
setEditDirectionality(type.defaultDirectionality || 'directed');
}; };
const handleSaveEdit = () => { const handleSaveEdit = (
if (!editingId || !editLabel.trim()) return; id: string,
updates: {
updateEdgeType(editingId, { label: string;
label: editLabel.trim(), color: string;
color: editColor, style: 'solid' | 'dashed' | 'dotted';
style: editStyle, defaultDirectionality: EdgeDirectionality;
defaultDirectionality: editDirectionality, }
}); ) => {
updateEdgeType(id, updates);
setEditingId(null); setEditingType(null);
showToast(`Relation type "${updates.label}" updated`, 'success');
}; };
const handleCancelEdit = () => { const handleCancelEdit = () => {
setEditingId(null); setEditingType(null);
}; };
const renderStylePreview = (style: 'solid' | 'dashed' | 'dotted', color: string) => { const handleDuplicateType = (type: EdgeTypeConfig) => {
const strokeDasharray = { // Generate a unique ID for the duplicate
solid: '0', let suffix = 2;
dashed: '8,4', let newId = `${type.id}-copy`;
dotted: '2,4', let newLabel = `${type.label} (Copy)`;
}[style];
return ( while (edgeTypes.some(et => et.id === newId)) {
<svg width="100%" height="20" className="mt-1"> newId = `${type.id}-copy-${suffix}`;
<line newLabel = `${type.label} (Copy ${suffix})`;
x1="0" suffix++;
y1="10" }
x2="100%"
y2="10" const duplicatedType: EdgeTypeConfig = {
stroke={color} id: newId,
strokeWidth="3" label: newLabel,
strokeDasharray={strokeDasharray} color: type.color,
/> style: type.style,
</svg> defaultDirectionality: type.defaultDirectionality,
); };
addEdgeType(duplicatedType);
showToast(`Relation type duplicated as "${newLabel}"`, 'success');
}; };
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<>
{/* Main Modal */}
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col"> <div className="bg-white rounded-lg shadow-xl w-full max-w-5xl max-h-[85vh] overflow-hidden flex flex-col">
{/* Header */} {/* Header */}
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Configure Relation Types</h2> <h2 className="text-xl font-bold text-gray-900">Configure Relation Types</h2>
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
Customize the types of relations that can connect actors Quickly add and manage the types of relations that can connect actors
</p> </p>
</div> </div>
{/* Content */} {/* Content - Two-Column or Full-Width Edit */}
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-hidden flex">
{/* Add New Type Form */} {editingType ? (
<div className="bg-gray-50 rounded-lg p-4 mb-6"> /* Full-Width Edit Mode */
<h3 className="text-sm font-semibold text-gray-700 mb-3">Add New Relation Type</h3> <div className="w-full p-6 overflow-y-auto">
<EdgeTypeForm <div className="max-w-2xl mx-auto">
name={newTypeName} <EditEdgeTypeInline
color={newTypeColor} type={editingType}
style={newTypeStyle} onSave={handleSaveEdit}
defaultDirectionality={newTypeDirectionality} onCancel={handleCancelEdit}
onNameChange={setNewTypeName}
onColorChange={setNewTypeColor}
onStyleChange={setNewTypeStyle}
onDefaultDirectionalityChange={setNewTypeDirectionality}
/> />
<button
onClick={handleAddType}
className="w-full mt-3 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
>
Add Relation Type
</button>
</div>
{/* Existing Types List */}
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-3">Existing Relation Types</h3>
<div className="space-y-2">
{edgeTypes.map((type) => (
<div
key={type.id}
className="border border-gray-200 rounded-md overflow-hidden"
>
{editingId === type.id ? (
// Edit mode
<div className="bg-blue-50 p-4">
<EdgeTypeForm
name={editLabel}
color={editColor}
style={editStyle}
defaultDirectionality={editDirectionality}
onNameChange={setEditLabel}
onColorChange={setEditColor}
onStyleChange={setEditStyle}
onDefaultDirectionalityChange={setEditDirectionality}
/>
<div className="flex space-x-2 mt-3">
<button
onClick={handleSaveEdit}
className="flex-1 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
>
Save
</button>
<button
onClick={handleCancelEdit}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
>
Cancel
</button>
</div> </div>
</div> </div>
) : ( ) : (
// View mode <>
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors"> {/* Left Column - Quick Add (60%) */}
<div className="flex-1"> <div className="w-3/5 border-r border-gray-200 p-6 overflow-y-auto">
<div className="text-sm font-medium text-gray-900 mb-1"> <div>
{type.label} <h3 className="text-sm font-semibold text-gray-700 mb-4">
</div> Quick Add Relation Type
<div className="w-full max-w-xs"> </h3>
{renderStylePreview(type.style || 'solid', type.color)} <QuickAddEdgeTypeForm onAdd={handleAddType} />
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleEditType(type)}
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded transition-colors"
>
Edit
</button>
<button
onClick={() => handleDeleteType(type.id)}
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors"
>
Delete
</button>
</div>
</div>
)}
</div>
))}
</div> </div>
{/* Helper Text */}
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="text-sm font-semibold text-blue-900 mb-1">Pro Tips</h4>
<ul className="text-xs text-blue-800 space-y-1">
<li> Press <kbd className="px-1 py-0.5 bg-white border border-blue-300 rounded text-xs">Enter</kbd> to quickly add a type</li>
<li> Choose meaningful names like "Supervises" or "Reports To"</li>
<li> Use different line styles to distinguish relation types visually</li>
<li> Click any type on the right to edit it</li>
</ul>
</div> </div>
</div> </div>
{/* Footer */} {/* Right Column - Management (40%) */}
<div className="w-2/5 p-6 overflow-y-auto bg-gray-50">
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-gray-700">
Relation Types ({edgeTypes.length})
</h3>
</div>
<EdgeTypeManagementList
types={edgeTypes}
onEdit={handleEditType}
onDelete={handleDeleteType}
onDuplicate={handleDuplicateType}
/>
</div>
</div>
</>
)}
</div>
{/* Footer - Hidden when editing */}
{!editingType && (
<div className="px-6 py-4 border-t border-gray-200 flex justify-end"> <div className="px-6 py-4 border-t border-gray-200 flex justify-end">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors" className="px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
> >
Close Done
</button> </button>
</div> </div>
)}
</div>
</div> </div>
{/* Confirmation Dialog */} {/* Confirmation Dialog */}
{ConfirmDialogComponent} {ConfirmDialogComponent}
</div> </>
); );
}; };

View file

@ -1,130 +0,0 @@
import type { EdgeDirectionality } from '../../types';
/**
* EdgeTypeForm - Reusable form fields for creating/editing edge types
*
* Features:
* - Name input
* - Color picker (visual + text input)
* - Line style selector (solid/dashed/dotted)
* - Default directionality selector
* - Visual style preview
*/
interface Props {
name: string;
color: string;
style: 'solid' | 'dashed' | 'dotted';
defaultDirectionality?: EdgeDirectionality;
onNameChange: (value: string) => void;
onColorChange: (value: string) => void;
onStyleChange: (value: 'solid' | 'dashed' | 'dotted') => void;
onDefaultDirectionalityChange?: (value: EdgeDirectionality) => void;
}
const EdgeTypeForm = ({
name,
color,
style,
defaultDirectionality = 'directed',
onNameChange,
onColorChange,
onStyleChange,
onDefaultDirectionalityChange,
}: Props) => {
const renderStylePreview = (lineStyle: 'solid' | 'dashed' | 'dotted', lineColor: string) => {
const strokeDasharray = {
solid: '0',
dashed: '8,4',
dotted: '2,4',
}[lineStyle];
return (
<svg width="100%" height="20" className="mt-1">
<line
x1="0"
y1="10"
x2="100%"
y2="10"
stroke={lineColor}
strokeWidth="3"
strokeDasharray={strokeDasharray}
/>
</svg>
);
};
return (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Name *
</label>
<input
type="text"
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g., Supervises, Communicates With"
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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Color *
</label>
<div className="flex items-center space-x-2">
<input
type="color"
value={color}
onChange={(e) => onColorChange(e.target.value)}
className="h-10 w-20 rounded cursor-pointer"
/>
<input
type="text"
value={color}
onChange={(e) => onColorChange(e.target.value)}
placeholder="#6366f1"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Line Style *
</label>
<select
value={style}
onChange={(e) => onStyleChange(e.target.value as 'solid' | 'dashed' | 'dotted')}
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"
>
<option value="solid">Solid</option>
<option value="dashed">Dashed</option>
<option value="dotted">Dotted</option>
</select>
{renderStylePreview(style, color)}
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Default Directionality *
</label>
<select
value={defaultDirectionality}
onChange={(e) => onDefaultDirectionalityChange?.(e.target.value as EdgeDirectionality)}
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"
>
<option value="directed">Directed () - One-way</option>
<option value="bidirectional">Bidirectional () - Two-way</option>
<option value="undirected">Undirected () - No direction</option>
</select>
<p className="text-xs text-gray-500 mt-1">
New relations of this type will use this directionality by default
</p>
</div>
</div>
);
};
export default EdgeTypeForm;

View file

@ -0,0 +1,152 @@
import { KeyboardEvent } from 'react';
import type { EdgeDirectionality } from '../../types';
/**
* EdgeTypeFormFields - Reusable form fields for add/edit relation types
*
* Features:
* - All fields visible
* - Compact single-row layout for name and color
* - Keyboard accessible
* - Consistent between add and edit modes
*/
interface Props {
name: string;
color: string;
style: 'solid' | 'dashed' | 'dotted';
defaultDirectionality: EdgeDirectionality;
onNameChange: (value: string) => void;
onColorChange: (value: string) => void;
onStyleChange: (value: 'solid' | 'dashed' | 'dotted') => void;
onDefaultDirectionalityChange: (value: EdgeDirectionality) => void;
onKeyDown?: (e: KeyboardEvent) => void;
nameInputRef?: React.RefObject<HTMLInputElement>;
autoFocusName?: boolean;
}
const EdgeTypeFormFields = ({
name,
color,
style,
defaultDirectionality,
onNameChange,
onColorChange,
onStyleChange,
onDefaultDirectionalityChange,
onKeyDown,
nameInputRef,
autoFocusName = false,
}: Props) => {
const renderStylePreview = (lineStyle: 'solid' | 'dashed' | 'dotted', lineColor: string) => {
const strokeDasharray = {
solid: '0',
dashed: '8,4',
dotted: '2,4',
}[lineStyle];
return (
<svg width="100%" height="20" className="mt-1">
<line
x1="0"
y1="10"
x2="100%"
y2="10"
stroke={lineColor}
strokeWidth="3"
strokeDasharray={strokeDasharray}
/>
</svg>
);
};
return (
<div className="space-y-3">
{/* Name, Color, and Line Style - Single row */}
<div>
<div className="flex items-end gap-2">
{/* Name */}
<div className="flex-1 min-w-0">
<label htmlFor="edge-type-name" className="block text-xs font-medium text-gray-700 mb-1">
Name <span className="text-red-500">*</span>
</label>
<input
id="edge-type-name"
ref={nameInputRef}
type="text"
value={name}
onChange={(e) => onNameChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="e.g., Supervises"
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}
/>
</div>
{/* Color */}
<div className="w-20 flex-shrink-0">
<label htmlFor="edge-type-color-picker" className="block text-xs font-medium text-gray-700 mb-1">
Color <span className="text-red-500">*</span>
</label>
<input
id="edge-type-color-picker"
type="color"
value={color}
onChange={(e) => onColorChange(e.target.value)}
onKeyDown={onKeyDown}
className="h-8 w-full rounded cursor-pointer border border-gray-300"
aria-label="Color picker"
/>
</div>
{/* Line Style */}
<div className="w-32 flex-shrink-0">
<label htmlFor="edge-type-style" className="block text-xs font-medium text-gray-700 mb-1">
Style <span className="text-red-500">*</span>
</label>
<select
id="edge-type-style"
value={style}
onChange={(e) => onStyleChange(e.target.value as 'solid' | 'dashed' | 'dotted')}
onKeyDown={onKeyDown}
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"
>
<option value="solid">Solid</option>
<option value="dashed">Dashed</option>
<option value="dotted">Dotted</option>
</select>
</div>
</div>
</div>
{/* Line Style Preview */}
<div>
{renderStylePreview(style, color)}
</div>
{/* Default Directionality */}
<div>
<label htmlFor="edge-type-directionality" className="block text-xs font-medium text-gray-700 mb-1">
Default Directionality <span className="text-red-500">*</span>
</label>
<select
id="edge-type-directionality"
value={defaultDirectionality}
onChange={(e) => onDefaultDirectionalityChange(e.target.value as EdgeDirectionality)}
onKeyDown={onKeyDown}
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"
>
<option value="directed">Directed () - One-way</option>
<option value="bidirectional">Bidirectional () - Two-way</option>
<option value="undirected">Undirected () - No direction</option>
</select>
<p className="text-xs text-gray-500 mt-1">
New relations of this type will use this directionality by default
</p>
</div>
</div>
);
};
export default EdgeTypeFormFields;

View file

@ -0,0 +1,119 @@
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import type { EdgeTypeConfig } from '../../types';
/**
* EdgeTypeManagementList - List view of existing relation types
*
* Features:
* - Scrollable list of relation types
* - Visual preview of line style and color
* - Edit, duplicate, and delete actions
* - Hover states for better UX
*/
interface Props {
types: EdgeTypeConfig[];
onEdit: (type: EdgeTypeConfig) => void;
onDelete: (id: string) => void;
onDuplicate: (type: EdgeTypeConfig) => void;
}
const EdgeTypeManagementList = ({ types, onEdit, onDelete, onDuplicate }: Props) => {
const renderStylePreview = (style: 'solid' | 'dashed' | 'dotted', color: string) => {
const strokeDasharray = {
solid: '0',
dashed: '8,4',
dotted: '2,4',
}[style];
return (
<svg width="100" height="20">
<line
x1="0"
y1="10"
x2="100"
y2="10"
stroke={color}
strokeWidth="3"
strokeDasharray={strokeDasharray}
/>
</svg>
);
};
if (types.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
<p className="text-sm">No relation types yet.</p>
<p className="text-xs mt-1">Add your first relation type above.</p>
</div>
);
}
return (
<div className="space-y-2">
{types.map((type) => (
<div
key={type.id}
className="group bg-white border border-gray-200 rounded-lg p-3 hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer"
onClick={() => onEdit(type)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onEdit(type);
}
}}
aria-label={`Edit ${type.label}`}
>
<div className="flex items-start justify-between gap-3">
{/* Type Info */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 mb-1">
{type.label}
</div>
<div className="flex items-center gap-2">
{renderStylePreview(type.style || 'solid', type.color)}
<span className="text-xs text-gray-500">
{type.defaultDirectionality === 'directed' && '→'}
{type.defaultDirectionality === 'bidirectional' && '↔'}
{type.defaultDirectionality === 'undirected' && '—'}
</span>
</div>
</div>
{/* Actions */}
<div className="flex gap-1">
<button
onClick={(e) => {
e.stopPropagation();
onDuplicate(type);
}}
className="p-1.5 text-gray-600 hover:bg-gray-100 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500"
aria-label={`Duplicate ${type.label}`}
title="Duplicate"
>
<ContentCopyIcon fontSize="small" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(type.id);
}}
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-red-500"
aria-label={`Delete ${type.label}`}
title="Delete"
>
<DeleteIcon fontSize="small" />
</button>
</div>
</div>
</div>
))}
</div>
);
};
export default EdgeTypeManagementList;

View file

@ -0,0 +1,121 @@
import { useState, useEffect, useRef, KeyboardEvent } from 'react';
import SaveIcon from '@mui/icons-material/Save';
import EdgeTypeFormFields from './EdgeTypeFormFields';
import type { EdgeTypeConfig, EdgeDirectionality } from '../../types';
/**
* EditEdgeTypeInline - Inline edit view that replaces the right column
*
* Features:
* - Replaces management list in right column when editing
* - Reuses EdgeTypeFormFields
* - Save/Cancel actions
* - Keyboard accessible (Cmd/Ctrl+Enter to save, Escape to cancel)
*/
interface Props {
type: EdgeTypeConfig;
onSave: (
id: string,
updates: {
label: string;
color: string;
style: 'solid' | 'dashed' | 'dotted';
defaultDirectionality: EdgeDirectionality;
}
) => void;
onCancel: () => void;
}
const EditEdgeTypeInline = ({ type, onSave, onCancel }: Props) => {
const [name, setName] = useState('');
const [color, setColor] = useState('#6366f1');
const [style, setStyle] = useState<'solid' | 'dashed' | 'dotted'>('solid');
const [defaultDirectionality, setDefaultDirectionality] = useState<EdgeDirectionality>('directed');
const nameInputRef = useRef<HTMLInputElement>(null);
// Sync state with type prop
useEffect(() => {
if (type) {
setName(type.label);
setColor(type.color);
setStyle(type.style || 'solid');
setDefaultDirectionality(type.defaultDirectionality || 'directed');
}
}, [type]);
const handleSave = () => {
if (!name.trim()) {
nameInputRef.current?.focus();
return;
}
onSave(type.id, {
label: name.trim(),
color,
style,
defaultDirectionality,
});
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
return (
<div className="flex flex-col min-h-full">
{/* Form Fields */}
<div className="flex-1 mb-6">
<EdgeTypeFormFields
name={name}
color={color}
style={style}
defaultDirectionality={defaultDirectionality}
onNameChange={setName}
onColorChange={setColor}
onStyleChange={setStyle}
onDefaultDirectionalityChange={setDefaultDirectionality}
onKeyDown={handleKeyDown}
nameInputRef={nameInputRef}
autoFocusName={true}
/>
</div>
{/* Actions */}
<div className="pt-6 space-y-3">
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 px-6 py-3 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Cancel
</button>
<button
onClick={handleSave}
className="flex-1 px-6 py-3 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 flex items-center justify-center gap-2"
>
<SaveIcon fontSize="small" />
Save Changes
</button>
</div>
{/* Keyboard Shortcut Hint */}
<div className="text-xs text-gray-500 text-center">
<kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">
{navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl'}+Enter
</kbd>{' '}
to save, <kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">Esc</kbd> to cancel
</div>
</div>
</div>
);
};
export default EditEdgeTypeInline;

View file

@ -1,17 +1,23 @@
import { useState } from 'react'; import { useState } from 'react';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory'; import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import NodeTypeForm from './NodeTypeForm';
import { useConfirm } from '../../hooks/useConfirm'; import { useConfirm } from '../../hooks/useConfirm';
import type { NodeTypeConfig } from '../../types'; import { useToastStore } from '../../stores/toastStore';
import QuickAddTypeForm from './QuickAddTypeForm';
import TypeManagementList from './TypeManagementList';
import EditTypeInline from './EditTypeInline';
import type { NodeTypeConfig, NodeShape } from '../../types';
/** /**
* NodeTypeConfig - Modal for managing actor/node types * NodeTypeConfig - Modal for managing actor/node types
* *
* Features: * Features:
* - Add new node types with custom name and color * - Two-column layout: quick add (left) + management/edit (right)
* - Edit existing node types * - Progressive disclosure for advanced options
* - Delete node types * - Inline editing replaces right column
* - Color picker for visual customization * - Compact card-based management list
* - Type duplication support
* - Toast notifications for actions
* - Full keyboard accessibility
*/ */
interface Props { interface Props {
@ -22,55 +28,34 @@ interface Props {
const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => { const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
const { nodeTypes, addNodeType, updateNodeType, deleteNodeType } = useGraphWithHistory(); const { nodeTypes, addNodeType, updateNodeType, deleteNodeType } = useGraphWithHistory();
const { confirm, ConfirmDialogComponent } = useConfirm(); const { confirm, ConfirmDialogComponent } = useConfirm();
const { showToast } = useToastStore();
const [newTypeName, setNewTypeName] = useState(''); const [editingType, setEditingType] = useState<NodeTypeConfig | null>(null);
const [newTypeColor, setNewTypeColor] = useState('#6366f1');
const [newTypeShape, setNewTypeShape] = useState<import('../../types').NodeShape>('rectangle');
const [newTypeDescription, setNewTypeDescription] = useState('');
const [newTypeIcon, setNewTypeIcon] = useState('');
// Editing state const handleAddType = (type: { name: string; color: string; shape: NodeShape; icon: string; description: string }) => {
const [editingId, setEditingId] = useState<string | null>(null); const id = type.name.toLowerCase().replace(/\s+/g, '-');
const [editLabel, setEditLabel] = useState('');
const [editColor, setEditColor] = useState('');
const [editShape, setEditShape] = useState<import('../../types').NodeShape>('rectangle');
const [editIcon, setEditIcon] = useState('');
const [editDescription, setEditDescription] = useState('');
const handleAddType = () => {
if (!newTypeName.trim()) {
alert('Please enter a name for the node type');
return;
}
const id = newTypeName.toLowerCase().replace(/\s+/g, '-');
// Check if ID already exists // Check if ID already exists
if (nodeTypes.some(nt => nt.id === id)) { if (nodeTypes.some(nt => nt.id === id)) {
alert('A node type with this name already exists'); showToast('A node type with this name already exists', 'error');
return; return;
} }
const newType: NodeTypeConfig = { const newType: NodeTypeConfig = {
id, id,
label: newTypeName.trim(), label: type.name,
color: newTypeColor, color: type.color,
shape: newTypeShape, shape: type.shape,
icon: newTypeIcon || undefined, icon: type.icon || undefined,
description: newTypeDescription.trim() || undefined, description: type.description || undefined,
}; };
addNodeType(newType); addNodeType(newType);
showToast(`Actor type "${type.name}" created`, 'success');
// Reset form
setNewTypeName('');
setNewTypeColor('#6366f1');
setNewTypeShape('rectangle');
setNewTypeDescription('');
setNewTypeIcon('');
}; };
const handleDeleteType = async (id: string) => { const handleDeleteType = async (id: string) => {
const type = nodeTypes.find(t => t.id === id);
const confirmed = await confirm({ const confirmed = await confirm({
title: 'Delete Actor Type', title: 'Delete Actor Type',
message: 'Are you sure you want to delete this actor type? This action cannot be undone.', message: 'Are you sure you want to delete this actor type? This action cannot be undone.',
@ -79,166 +64,137 @@ const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
}); });
if (confirmed) { if (confirmed) {
deleteNodeType(id); deleteNodeType(id);
showToast(`Actor type "${type?.label}" deleted`, 'success');
} }
}; };
const handleEditType = (type: NodeTypeConfig) => { const handleEditType = (type: NodeTypeConfig) => {
setEditingId(type.id); setEditingType(type);
setEditLabel(type.label);
setEditColor(type.color);
setEditShape(type.shape || 'rectangle');
setEditIcon(type.icon || '');
setEditDescription(type.description || '');
}; };
const handleSaveEdit = () => { const handleSaveEdit = (id: string, updates: { label: string; color: string; shape: NodeShape; icon?: string; description?: string }) => {
if (!editingId || !editLabel.trim()) return; updateNodeType(id, updates);
setEditingType(null);
updateNodeType(editingId, { showToast(`Actor type "${updates.label}" updated`, 'success');
label: editLabel.trim(),
color: editColor,
shape: editShape,
icon: editIcon || undefined,
description: editDescription.trim() || undefined,
});
setEditingId(null);
}; };
const handleCancelEdit = () => { const handleCancelEdit = () => {
setEditingId(null); setEditingType(null);
};
const handleDuplicateType = (type: NodeTypeConfig) => {
// Generate a unique ID for the duplicate
let suffix = 2;
let newId = `${type.id}-copy`;
let newLabel = `${type.label} (Copy)`;
while (nodeTypes.some(nt => nt.id === newId)) {
newId = `${type.id}-copy-${suffix}`;
newLabel = `${type.label} (Copy ${suffix})`;
suffix++;
}
const duplicatedType: NodeTypeConfig = {
id: newId,
label: newLabel,
color: type.color,
shape: type.shape,
icon: type.icon,
description: type.description,
};
addNodeType(duplicatedType);
showToast(`Actor type duplicated as "${newLabel}"`, 'success');
}; };
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<>
{/* Main Modal */}
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col"> <div className="bg-white rounded-lg shadow-xl w-full max-w-5xl max-h-[85vh] overflow-hidden flex flex-col">
{/* Header */} {/* Header */}
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Configure Actor Types</h2> <h2 className="text-xl font-bold text-gray-900">Configure Actor Types</h2>
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
Customize the types of actors that can be added to your constellation Quickly add and manage the types of actors in your constellation
</p> </p>
</div> </div>
{/* Content */} {/* Content - Two-Column or Full-Width Edit */}
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-hidden flex">
{/* Add New Type Form */} {editingType ? (
<div className="bg-gray-50 rounded-lg p-4 mb-6"> /* Full-Width Edit Mode */
<h3 className="text-sm font-semibold text-gray-700 mb-3">Add New Actor Type</h3> <div className="w-full p-6 overflow-y-auto">
<NodeTypeForm <div className="max-w-2xl mx-auto">
name={newTypeName} <EditTypeInline
color={newTypeColor} type={editingType}
shape={newTypeShape} onSave={handleSaveEdit}
icon={newTypeIcon} onCancel={handleCancelEdit}
description={newTypeDescription}
onNameChange={setNewTypeName}
onColorChange={setNewTypeColor}
onShapeChange={setNewTypeShape}
onIconChange={setNewTypeIcon}
onDescriptionChange={setNewTypeDescription}
/> />
<button
onClick={handleAddType}
className="w-full mt-3 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
>
Add Actor Type
</button>
</div>
{/* Existing Types List */}
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-3">Existing Actor Types</h3>
<div className="space-y-2">
{nodeTypes.map((type) => (
<div
key={type.id}
className="border border-gray-200 rounded-md overflow-hidden"
>
{editingId === type.id ? (
// Edit mode
<div className="bg-blue-50 p-4">
<NodeTypeForm
name={editLabel}
color={editColor}
shape={editShape}
icon={editIcon}
description={editDescription}
onNameChange={setEditLabel}
onColorChange={setEditColor}
onShapeChange={setEditShape}
onIconChange={setEditIcon}
onDescriptionChange={setEditDescription}
/>
<div className="flex space-x-2 mt-3">
<button
onClick={handleSaveEdit}
className="flex-1 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
>
Save
</button>
<button
onClick={handleCancelEdit}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
>
Cancel
</button>
</div> </div>
</div> </div>
) : ( ) : (
// View mode <>
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors"> {/* Left Column - Quick Add (60%) */}
<div className="flex items-center space-x-3 flex-1"> <div className="w-3/5 border-r border-gray-200 p-6 overflow-y-auto">
<div <div>
className="w-8 h-8 rounded" <h3 className="text-sm font-semibold text-gray-700 mb-4">
style={{ backgroundColor: type.color }} Quick Add Actor Type
/> </h3>
<div className="flex-1"> <QuickAddTypeForm onAdd={handleAddType} />
<div className="text-sm font-medium text-gray-900">
{type.label}
</div>
{type.description && (
<div className="text-xs text-gray-500">{type.description}</div>
)}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleEditType(type)}
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded transition-colors"
>
Edit
</button>
<button
onClick={() => handleDeleteType(type.id)}
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors"
>
Delete
</button>
</div>
</div>
)}
</div>
))}
</div> </div>
{/* Helper Text */}
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="text-sm font-semibold text-blue-900 mb-1">Pro Tips</h4>
<ul className="text-xs text-blue-800 space-y-1">
<li> Press <kbd className="px-1 py-0.5 bg-white border border-blue-300 rounded text-xs">Enter</kbd> to quickly add a type</li>
<li> Shape and icon are optional - focus on name and color first</li>
<li> Click any type on the right to edit it</li>
<li> Use duplicate to create variations quickly</li>
</ul>
</div> </div>
</div> </div>
{/* Footer */} {/* Right Column - Management (40%) */}
<div className="w-2/5 p-6 overflow-y-auto bg-gray-50">
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-gray-700">
Actor Types ({nodeTypes.length})
</h3>
</div>
<TypeManagementList
types={nodeTypes}
onEdit={handleEditType}
onDelete={handleDeleteType}
onDuplicate={handleDuplicateType}
/>
</div>
</div>
</>
)}
</div>
{/* Footer - Hidden when editing */}
{!editingType && (
<div className="px-6 py-4 border-t border-gray-200 flex justify-end"> <div className="px-6 py-4 border-t border-gray-200 flex justify-end">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors" className="px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
> >
Close Done
</button> </button>
</div> </div>
)}
</div>
</div> </div>
{/* Confirmation Dialog */} {/* Confirmation Dialog */}
{ConfirmDialogComponent} {ConfirmDialogComponent}
</div> </>
); );
}; };

View file

@ -0,0 +1,97 @@
import { useState, useRef, KeyboardEvent } from 'react';
import AddIcon from '@mui/icons-material/Add';
import EdgeTypeFormFields from './EdgeTypeFormFields';
import type { EdgeDirectionality } from '../../types';
/**
* QuickAddEdgeTypeForm - Quick add form for new relation types
*
* Features:
* - Compact form using EdgeTypeFormFields
* - Keyboard accessible (Cmd/Ctrl+Enter to add)
* - Auto-clears after successful add
* - Focus management
*/
interface Props {
onAdd: (data: {
label: string;
color: string;
style: 'solid' | 'dashed' | 'dotted';
defaultDirectionality: EdgeDirectionality;
}) => void;
}
const QuickAddEdgeTypeForm = ({ onAdd }: Props) => {
const [name, setName] = useState('');
const [color, setColor] = useState('#6366f1');
const [style, setStyle] = useState<'solid' | 'dashed' | 'dotted'>('solid');
const [defaultDirectionality, setDefaultDirectionality] = useState<EdgeDirectionality>('directed');
const nameInputRef = useRef<HTMLInputElement>(null);
const handleAdd = () => {
if (!name.trim()) {
nameInputRef.current?.focus();
return;
}
onAdd({
label: name.trim(),
color,
style,
defaultDirectionality,
});
// Reset form
setName('');
setColor('#6366f1');
setStyle('solid');
setDefaultDirectionality('directed');
nameInputRef.current?.focus();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleAdd();
}
};
return (
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Add New Relation Type</h3>
<EdgeTypeFormFields
name={name}
color={color}
style={style}
defaultDirectionality={defaultDirectionality}
onNameChange={setName}
onColorChange={setColor}
onStyleChange={setStyle}
onDefaultDirectionalityChange={setDefaultDirectionality}
onKeyDown={handleKeyDown}
nameInputRef={nameInputRef}
autoFocusName={false}
/>
<button
onClick={handleAdd}
className="w-full mt-4 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 flex items-center justify-center gap-2"
>
<AddIcon fontSize="small" />
Add Relation Type
</button>
<div className="text-xs text-gray-500 text-center mt-2">
<kbd className="px-1.5 py-0.5 bg-gray-100 border border-gray-300 rounded text-xs">
{navigator.platform.includes('Mac') ? 'Cmd' : 'Ctrl'}+Enter
</kbd>{' '}
to add
</div>
</div>
);
};
export default QuickAddEdgeTypeForm;

View file

@ -0,0 +1,106 @@
import { useState, useRef, KeyboardEvent } from 'react';
import TypeFormFields from './TypeFormFields';
import type { NodeShape } from '../../types';
/**
* QuickAddTypeForm - Streamlined form for quickly adding new actor types
*
* Features:
* - One-line quick add (name + color + button)
* - Progressive disclosure for advanced options
* - Keyboard accessible (Enter to submit, Escape to cancel)
* - Focus management
*/
interface Props {
onAdd: (type: {
name: string;
color: string;
shape: NodeShape;
icon: string;
description: string;
}) => void;
}
const QuickAddTypeForm = ({ onAdd }: Props) => {
const [name, setName] = useState('');
const [color, setColor] = useState('#6366f1');
const [shape, setShape] = useState<NodeShape>('rectangle');
const [icon, setIcon] = useState('');
const [description, setDescription] = useState('');
const nameInputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
if (!name.trim()) {
nameInputRef.current?.focus();
return;
}
onAdd({ name: name.trim(), color, shape, icon, description });
// Reset form
setName('');
setColor('#6366f1');
setShape('rectangle');
setIcon('');
setDescription('');
// Focus back to name input for quick subsequent additions
nameInputRef.current?.focus();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
} else if (e.key === 'Escape') {
e.preventDefault();
// Reset form
setName('');
setColor('#6366f1');
setShape('rectangle');
setIcon('');
setDescription('');
nameInputRef.current?.blur();
}
};
return (
<div className="space-y-3">
<TypeFormFields
name={name}
color={color}
shape={shape}
icon={icon}
description={description}
onNameChange={setName}
onColorChange={setColor}
onShapeChange={setShape}
onIconChange={setIcon}
onDescriptionChange={setDescription}
onKeyDown={handleKeyDown}
nameInputRef={nameInputRef}
autoFocusName={false}
showAdvancedByDefault={false}
/>
<button
onClick={handleSubmit}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
aria-label="Add actor type"
>
Add Type
</button>
{/* Keyboard Shortcuts Hint */}
{name && (
<div className="text-xs text-gray-500 italic">
Press Enter to add, Escape to cancel
</div>
)}
</div>
);
};
export default QuickAddTypeForm;

View file

@ -49,10 +49,10 @@ const SHAPE_OPTIONS: ShapeOption[] = [
const ShapeSelector = ({ value, onChange, color = '#3b82f6' }: ShapeSelectorProps) => { const ShapeSelector = ({ value, onChange, color = '#3b82f6' }: ShapeSelectorProps) => {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700"> <label className="block text-xs font-medium text-gray-700">
Shape Shape (optional)
</label> </label>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-5 gap-2">
{SHAPE_OPTIONS.map((option) => { {SHAPE_OPTIONS.map((option) => {
const isSelected = value === option.id; const isSelected = value === option.id;
return ( return (
@ -61,27 +61,30 @@ const ShapeSelector = ({ value, onChange, color = '#3b82f6' }: ShapeSelectorProp
type="button" type="button"
onClick={() => onChange(option.id)} onClick={() => onChange(option.id)}
className={` className={`
relative p-3 rounded-lg border-2 transition-all relative p-2 rounded-md border-2 transition-all
hover:border-blue-400 hover:bg-blue-50 hover:border-blue-400 hover:bg-blue-50
focus:outline-none focus:ring-2 focus:ring-blue-500
${isSelected ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-white'} ${isSelected ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-white'}
`} `}
title={option.description} title={option.description}
aria-label={option.label}
aria-pressed={isSelected}
> >
{/* Shape Preview */} {/* Shape Preview */}
<div className="flex justify-center items-center h-12 mb-2"> <div className="flex justify-center items-center h-8">
<ShapePreview shape={option.id} color={color} size={40} /> <ShapePreview shape={option.id} color={color} size={28} />
</div> </div>
{/* Shape Label */} {/* Shape Label */}
<div className="text-xs text-center text-gray-700 font-medium"> <div className="text-xs text-center text-gray-700 font-medium mt-1 truncate">
{option.label} {option.label.split(' ')[0]}
</div> </div>
{/* Selected Indicator */} {/* Selected Indicator */}
{isSelected && ( {isSelected && (
<div className="absolute top-1 right-1 w-4 h-4 bg-blue-500 rounded-full flex items-center justify-center"> <div className="absolute top-0.5 right-0.5 w-3 h-3 bg-blue-500 rounded-full flex items-center justify-center">
<svg <svg
className="w-3 h-3 text-white" className="w-2 h-2 text-white"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"

View file

@ -0,0 +1,106 @@
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import type { NodeTypeConfig } from '../../types';
/**
* TypeManagementList - Compact list view for managing existing actor types
*
* Features:
* - White background cards with click to edit
* - Color badge + name + description preview
* - Always visible duplicate and delete buttons
* - Keyboard accessible
* - ARIA compliant
*/
interface Props {
types: NodeTypeConfig[];
onEdit: (type: NodeTypeConfig) => void;
onDelete: (id: string) => void;
onDuplicate: (type: NodeTypeConfig) => void;
}
const TypeManagementList = ({ types, onEdit, onDelete, onDuplicate }: Props) => {
if (types.length === 0) {
return (
<div className="text-center py-12 text-gray-500">
<p className="text-sm">No actor types yet.</p>
<p className="text-xs mt-1">Add your first actor type above.</p>
</div>
);
}
return (
<div className="space-y-2">
{types.map((type) => (
<div
key={type.id}
className="group bg-white border border-gray-200 rounded-lg p-3 hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer"
onClick={() => onEdit(type)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onEdit(type);
}
}}
aria-label={`Edit ${type.label}`}
>
<div className="flex items-start justify-between gap-3">
{/* Type Info */}
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Color Badge */}
<div
className="w-10 h-10 rounded flex-shrink-0"
style={{ backgroundColor: type.color }}
aria-hidden="true"
/>
{/* Name & Description */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900">
{type.label}
</div>
{type.description && (
<div className="text-xs text-gray-500 truncate mt-0.5">
{type.description}
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-1">
<button
onClick={(e) => {
e.stopPropagation();
onDuplicate(type);
}}
className="p-1.5 text-gray-600 hover:bg-gray-100 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500"
aria-label={`Duplicate ${type.label}`}
title="Duplicate"
>
<ContentCopyIcon fontSize="small" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(type.id);
}}
className="p-1.5 text-red-600 hover:bg-red-50 rounded transition-colors focus:outline-none focus:ring-2 focus:ring-red-500"
aria-label={`Delete ${type.label}`}
title="Delete"
>
<DeleteIcon fontSize="small" />
</button>
</div>
</div>
</div>
))}
</div>
);
};
export default TypeManagementList;

View file

@ -48,3 +48,19 @@ code {
button { button {
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
} }
/* Slide-in animation for edit panel */
@keyframes slideInLeft {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in-left {
animation: slideInLeft 0.3s ease-out forwards;
}