resource-portal / frontend /src /components /projects /PhaseCreatePopover.tsx
Gowrisankar
Improve Projects planner phase drag and milestone row styling.
bcf0657
import { format } from "date-fns";
import { Calendar } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
export const DEFAULT_PHASE_COLOR = "#14b8a6";
const PHASE_COLOR_OPTIONS = [
"#14b8a6",
"#0ea5e9",
"#6366f1",
"#a855f7",
"#ec4899",
"#f59e0b",
"#22c55e",
"#64748b",
];
export interface PhaseCreateInput {
name: string;
start_date: string;
end_date: string;
color: string;
}
interface PhaseCreatePopoverProps {
anchor: { top: number; left: number };
startDate: string;
endDate: string;
onCancel: () => void;
onSave: (input: PhaseCreateInput) => void;
isSaving?: boolean;
}
export function PhaseCreatePopover({
anchor,
startDate,
endDate,
onCancel,
onSave,
isSaving = false,
}: PhaseCreatePopoverProps) {
const popoverRef = useRef<HTMLDivElement>(null);
const [name, setName] = useState("Phase");
const [color, setColor] = useState(DEFAULT_PHASE_COLOR);
const [showColors, setShowColors] = useState(false);
useEffect(() => {
setName("Phase");
setColor(DEFAULT_PHASE_COLOR);
setShowColors(false);
}, [startDate, endDate]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onCancel();
};
const onDown = (event: MouseEvent) => {
if (popoverRef.current?.contains(event.target as Node)) return;
onCancel();
};
window.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onDown);
return () => {
window.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onDown);
};
}, [onCancel]);
const rangeLabel =
startDate === endDate
? format(new Date(startDate), "d MMM yyyy")
: `${format(new Date(startDate), "d MMM")}${format(new Date(endDate), "d MMM yyyy")}`;
const style = {
top: Math.min(anchor.top, window.innerHeight - 200),
left: Math.min(Math.max(8, anchor.left), window.innerWidth - 320),
};
return createPortal(
<div className="phase-create-popover" ref={popoverRef} style={style}>
<input
aria-label="Phase name"
autoFocus
className="phase-create-popover-input"
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && name.trim()) {
onSave({ name: name.trim(), start_date: startDate, end_date: endDate, color });
}
}}
placeholder="Phase"
type="text"
value={name}
/>
<div className="phase-create-popover-footer">
<button className="link-button" onClick={onCancel} type="button">
Cancel
</button>
<div className="phase-create-popover-tools">
<div className="phase-color-picker">
<button
aria-label="Phase color"
className="phase-color-trigger"
onClick={() => setShowColors((open) => !open)}
style={{ background: color }}
type="button"
/>
{showColors ? (
<div className="phase-color-menu">
{PHASE_COLOR_OPTIONS.map((option) => (
<button
aria-label={`Color ${option}`}
className={option === color ? "is-selected" : undefined}
key={option}
onClick={() => {
setColor(option);
setShowColors(false);
}}
style={{ background: option }}
type="button"
/>
))}
</div>
) : null}
</div>
<span className="phase-date-hint" title={rangeLabel}>
<Calendar aria-hidden size={16} />
</span>
<button
className="primary-button"
disabled={isSaving || !name.trim()}
onClick={() =>
onSave({ name: name.trim(), start_date: startDate, end_date: endDate, color })
}
type="button"
>
Save
</button>
</div>
</div>
</div>,
document.body,
);
}
export function phasePopoverAnchorFromTrack(
trackRect: DOMRect,
startIndex: number,
endIndex: number,
totalDays: number,
): { top: number; left: number } {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const span = end - start + 1;
const leftPct = start / totalDays;
const widthPct = span / totalDays;
const centerX = trackRect.left + trackRect.width * (leftPct + widthPct / 2);
return { top: trackRect.top - 12, left: centerX - 150 };
}