feat: improve actor type form layout and UX

Consolidates name, color, and icon fields into a single horizontal row
for more compact form layout. Removes progressive disclosure pattern to
show all fields at once.

Changes:
- Single-row layout for name, color, and icon fields with proper alignment
- Name field uses flex-1 with min-w-0 for proper text truncation
- Color picker fixed at h-8 to match input heights
- Icon popover uses fixed positioning (z-9999) to prevent clipping
- All form fields now always visible (removed collapsible advanced options)
- Removed edit panel header for cleaner UI
- Removed border above action buttons

🤖 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:37:24 +02:00
parent e0784ff3d8
commit bc6ffb5bc3
3 changed files with 496 additions and 0 deletions

View file

@ -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<NodeShape>('rectangle');
const [icon, setIcon] = useState('');
const [description, setDescription] = useState('');
const nameInputRef = useRef<HTMLInputElement>(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 (
<div className="flex flex-col min-h-full">
{/* Form Fields */}
<div className="flex-1 mb-6">
<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={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 EditTypeInline;

View file

@ -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<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(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 (
<div className="relative">
{/* Trigger Button */}
<button
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-haspopup="true"
aria-expanded={isOpen}
aria-label={selectedIcon ? `Selected icon: ${selectedIcon}. Click to change` : 'No icon selected. Click to choose'}
>
{SelectedIconComponent ? (
<>
<SelectedIconComponent fontSize="small" className="text-gray-700" />
<span className="text-sm text-gray-700">{selectedIcon}</span>
</>
) : (
<span className="text-sm text-gray-500">No icon</span>
)}
<CloseIcon
fontSize="small"
className="text-gray-400 ml-auto"
style={{ transform: isOpen ? 'rotate(45deg)' : 'none', transition: 'transform 0.2s' }}
/>
</button>
{/* Popover */}
{isOpen && (
<div
ref={popoverRef}
className="fixed bg-white border border-gray-300 rounded-lg shadow-lg z-[9999] p-3"
style={{
width: '320px',
top: buttonRef.current ? `${buttonRef.current.getBoundingClientRect().bottom + 4}px` : '0',
left: buttonRef.current ? `${buttonRef.current.getBoundingClientRect().left}px` : '0',
}}
role="dialog"
aria-label="Icon picker"
>
<div className="grid grid-cols-8 gap-2 max-h-64 overflow-y-auto">
{/* No icon option */}
<button
type="button"
onClick={() => handleSelectIcon('')}
onKeyDown={(e) => handleKeyDown(e, '', 0)}
className={`
p-2 rounded border-2 transition-all flex items-center justify-center
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
${!selectedIcon
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
title="No icon"
aria-label="No icon"
aria-pressed={!selectedIcon}
>
<span className="text-xs text-gray-500"></span>
</button>
{/* Icon options */}
{availableIcons.map((icon, index) => {
const IconComponent = icon.component;
const actualIndex = index + 1; // +1 because "no icon" is at index 0
return (
<button
key={icon.name}
type="button"
onClick={() => handleSelectIcon(icon.name)}
onKeyDown={(e) => handleKeyDown(e, icon.name, actualIndex)}
className={`
p-2 rounded border-2 transition-all flex items-center justify-center
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
${selectedIcon === icon.name
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
title={icon.name}
aria-label={icon.name}
aria-pressed={selectedIcon === icon.name}
>
<IconComponent fontSize="small" className="text-gray-700" />
</button>
);
})}
</div>
</div>
)}
</div>
);
};
export default IconPopover;

View file

@ -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<HTMLInputElement>;
autoFocusName?: boolean;
}
const TypeFormFields = ({
name,
color,
shape,
icon,
description,
onNameChange,
onColorChange,
onShapeChange,
onIconChange,
onDescriptionChange,
onKeyDown,
nameInputRef,
autoFocusName = false,
}: Props) => {
return (
<div className="space-y-3">
{/* Name, Color, and Icon - Single row */}
<div>
<div className="flex items-end gap-2">
{/* Name */}
<div className="flex-1 min-w-0">
<label htmlFor="type-name" className="block text-xs font-medium text-gray-700 mb-1">
Name <span className="text-red-500">*</span>
</label>
<input
id="type-name"
ref={nameInputRef}
type="text"
value={name}
onChange={(e) => 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}
/>
</div>
{/* Color */}
<div className="w-20 flex-shrink-0">
<label htmlFor="type-color-picker" className="block text-xs font-medium text-gray-700 mb-1">
Color <span className="text-red-500">*</span>
</label>
<input
id="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>
{/* Icon */}
<div className="flex-shrink-0">
<label className="block text-xs font-medium text-gray-700 mb-1">
Icon
</label>
<IconPopover selectedIcon={icon} onSelect={onIconChange} />
</div>
</div>
</div>
{/* Shape Selector */}
<ShapeSelector value={shape} onChange={onShapeChange} color={color} />
{/* Description */}
<div>
<label
htmlFor="type-description"
className="block text-xs font-medium text-gray-700 mb-1"
>
Description (optional)
</label>
<textarea
id="type-description"
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="Brief description of this actor type"
rows={3}
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 resize-none"
/>
</div>
</div>
);
};
export default TypeFormFields;