Add CSS transitions and animations for smooth UI interactions
Animate course set pin/unpin with cross-fade content swap, credit bar width changes, status badge color transitions, expand/collapse panels (CreditLegend, AllocationBreakdown), mode toggle switching, and ModeComparison banner fade. Specialization rows flash on credit/status changes. Threshold markers animate position. All animations respect prefers-reduced-motion.
This commit is contained in:
@@ -51,11 +51,13 @@ function ElectiveSet({
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: isPinned ? '1px solid #3b82f6' : '1px dashed #ccc',
|
||||
border: isPinned ? '1px solid #3b82f6' : '1px solid #ccc',
|
||||
borderStyle: isPinned ? 'solid' : 'dashed',
|
||||
borderRadius: '8px',
|
||||
padding: '12px',
|
||||
marginBottom: '8px',
|
||||
background: isPinned ? '#eff6ff' : '#fafafa',
|
||||
transition: 'border-color 200ms, background-color 200ms',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
@@ -78,11 +80,25 @@ function ElectiveSet({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isPinned ? (
|
||||
{/* Pinned view */}
|
||||
<div style={{
|
||||
maxHeight: isPinned ? '40px' : '0',
|
||||
opacity: isPinned ? 1 : 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height 250ms ease-out, opacity 200ms',
|
||||
}}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: '#1e40af' }}>
|
||||
{pinnedCourse?.name}
|
||||
</div>
|
||||
) : (
|
||||
</div>
|
||||
{/* Course list view */}
|
||||
<div style={{
|
||||
maxHeight: isPinned ? '0' : '500px',
|
||||
opacity: isPinned ? 0 : 1,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: isPinned ? 'none' : 'auto',
|
||||
transition: 'max-height 250ms ease-out, opacity 200ms',
|
||||
}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{courses.map((course) => {
|
||||
const ceiling = ceilingMap.get(course.id);
|
||||
@@ -136,7 +152,7 @@ function ElectiveSet({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ export function CreditLegend() {
|
||||
>
|
||||
{open ? '▾ How to read this' : '▸ How to read this'}
|
||||
</button>
|
||||
{open && (
|
||||
<div style={{
|
||||
maxHeight: open ? '300px' : '0',
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height 200ms ease-out',
|
||||
}}>
|
||||
<div style={{ marginTop: '6px', padding: '10px', background: '#f9fafb', borderRadius: '6px', border: '1px solid #e5e7eb', color: '#555', lineHeight: 1.6 }}>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<strong>Credit bar:</strong>
|
||||
@@ -42,7 +46,7 @@ export function CreditLegend() {
|
||||
Maximum 3 specializations can be achieved (30 total credits ÷ 9 per specialization).
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export function ModeToggle({ mode, onSetMode }: ModeToggleProps) {
|
||||
background: mode === 'maximize-count' ? '#fff' : 'transparent',
|
||||
boxShadow: mode === 'maximize-count' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
color: mode === 'maximize-count' ? '#111' : '#666',
|
||||
transition: 'background 150ms, box-shadow 150ms, color 150ms',
|
||||
}}
|
||||
>
|
||||
Maximize Count
|
||||
@@ -40,6 +41,7 @@ export function ModeToggle({ mode, onSetMode }: ModeToggleProps) {
|
||||
background: mode === 'priority-order' ? '#fff' : 'transparent',
|
||||
boxShadow: mode === 'priority-order' ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
||||
color: mode === 'priority-order' ? '#111' : '#666',
|
||||
transition: 'background 150ms, box-shadow 150ms, color 150ms',
|
||||
}}
|
||||
>
|
||||
Priority Order
|
||||
|
||||
@@ -12,18 +12,22 @@ export function ModeComparison({
|
||||
const currentSpecs = new Set(result.achieved);
|
||||
const altSpecs = new Set(altResult.achieved);
|
||||
|
||||
if (
|
||||
const isVisible = !(
|
||||
currentSpecs.size === altSpecs.size &&
|
||||
result.achieved.every((s) => altSpecs.has(s))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: '#fef3c7', border: '1px solid #fcd34d', borderRadius: '6px',
|
||||
padding: '10px', marginBottom: '8px', fontSize: '12px',
|
||||
fontSize: '12px',
|
||||
overflow: 'hidden',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
maxHeight: isVisible ? '80px' : '0',
|
||||
padding: isVisible ? '10px' : '0 10px',
|
||||
marginBottom: isVisible ? '8px' : '0',
|
||||
transition: 'opacity 200ms, max-height 200ms ease-out, padding 200ms, margin-bottom 200ms',
|
||||
}}
|
||||
>
|
||||
<strong>Mode comparison:</strong> {altModeName} achieves {altResult.achieved.length} specialization
|
||||
@@ -32,4 +36,3 @@ export function ModeComparison({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ function CreditBar({ allocated, potential, threshold }: { allocated: number; pot
|
||||
style={{
|
||||
position: 'absolute', left: 0, top: 0, height: '100%',
|
||||
width: `${potentialPct}%`, background: '#bfdbfe', borderRadius: '3px',
|
||||
transition: 'width 300ms ease-out',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -47,13 +48,14 @@ function CreditBar({ allocated, potential, threshold }: { allocated: number; pot
|
||||
style={{
|
||||
position: 'absolute', left: 0, top: 0, height: '100%',
|
||||
width: `${allocPct}%`, background: allocated >= threshold ? '#22c55e' : '#3b82f6',
|
||||
borderRadius: '3px',
|
||||
borderRadius: '3px', transition: 'width 300ms ease-out',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute', left: `${(threshold / maxWidth) * 100}%`, top: '-2px',
|
||||
width: '2px', height: '10px', background: '#666',
|
||||
transition: 'left 300ms ease-out',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -105,22 +107,40 @@ function SortableItem({ id, rank, total, name, status, allocated, potential, isE
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
transition: dndTransition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = STATUS_STYLES[status] || STATUS_STYLES.unreachable;
|
||||
const isAchieved = status === 'achieved';
|
||||
|
||||
// Track changes and flash on status/credit updates
|
||||
const prevStatusRef = useRef(status);
|
||||
const prevAllocatedRef = useRef(allocated);
|
||||
const [flash, setFlash] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const statusChanged = prevStatusRef.current !== status;
|
||||
const creditsChanged = prevAllocatedRef.current !== allocated;
|
||||
prevStatusRef.current = status;
|
||||
prevAllocatedRef.current = allocated;
|
||||
if (statusChanged || creditsChanged) {
|
||||
setFlash(true);
|
||||
const timer = setTimeout(() => setFlash(false), 400);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [status, allocated]);
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
transition: [dndTransition, 'background 400ms ease-out, box-shadow 400ms ease-out'].filter(Boolean).join(', '),
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
marginBottom: '4px',
|
||||
borderRadius: '6px',
|
||||
background: isDragging ? '#e8e8e8' : style.bg,
|
||||
background: isDragging ? '#e8e8e8' : flash ? '#fef9c3' : style.bg,
|
||||
border: '1px solid #ddd',
|
||||
padding: '6px 10px',
|
||||
boxShadow: flash ? '0 0 0 2px #facc15' : 'none',
|
||||
};
|
||||
|
||||
const arrowBtn: React.CSSProperties = {
|
||||
@@ -159,16 +179,20 @@ function SortableItem({ id, rank, total, name, status, allocated, potential, isE
|
||||
style={{
|
||||
fontSize: '11px', padding: '2px 6px', borderRadius: '10px',
|
||||
background: style.color + '20', color: style.color, fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
whiteSpace: 'nowrap', transition: 'background 200ms, color 200ms',
|
||||
}}
|
||||
>
|
||||
{style.label}
|
||||
</span>
|
||||
</div>
|
||||
<CreditBar allocated={allocated} potential={potential} threshold={9} />
|
||||
{isAchieved && isExpanded && (
|
||||
<div style={{
|
||||
maxHeight: isAchieved && isExpanded ? '200px' : '0',
|
||||
overflow: 'hidden',
|
||||
transition: 'max-height 200ms ease-out',
|
||||
}}>
|
||||
<AllocationBreakdown specId={id} allocations={allocations} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,3 +26,10 @@ h1, h2, h3, h4 {
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user